Compare commits

..

37 Commits

Author SHA1 Message Date
Psychotoxical 9b4eb0982c feat: new app icon, AUR package, remove AppImage (v1.4.4)
- New app icon across all platforms
- AUR package: Arch/CachyOS users can install via yay/paru
- AppImage removed: incompatible with non-Ubuntu distros due to
  bundled WebKitGTK vs system Mesa/EGL conflicts
- CI: build only deb+rpm for Linux, drop GStreamer/linuxdeploy deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:10:08 +01:00
Psychotoxical 8ec642f368 fix: Random Mix improvements, queue UX fixes (v1.4.3)
- Random Mix: remove redundant Play All button in genre mix section
- Random Mix: Play All disabled with live counter during genre mix loading
- Random Mix: cap genre fetch at 50 genres to prevent over-fetching
- Random Mix: cache-bust getRandomSongs to always get fresh results
- Random Mix: clear songs on remix to fix display/play mismatch
- Queue: show song count and total duration in header
- Queue: context-active class keeps hover highlight on right-click
- Context Menu: add Favorite option for queue-item type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:38:27 +01:00
Psychotoxical 73f836b2ee fix: AppImage EGL crash on modern distros, update link capability (v1.4.2)
- Switch Linux CI build from ubuntu-22.04 to ubuntu-24.04 — bundles
  WebKitGTK 2.44 which uses eglGetPlatformDisplay instead of the
  deprecated eglGetDisplay(EGL_DEFAULT_DISPLAY) that fails on Mesa 25.x
  (CachyOS/Arch with RDNA 4 and other modern hardware)
- Add EGL_PLATFORM=x11 to AppRun as additional safeguard for XWayland
- Fix shell:allow-open capability missing URL scope (update toast link
  was silently blocked by Tauri v2 without explicit allow-list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 21:26:33 +01:00
Psychotoxical af18aef42a fix: random albums performance, image cache memory leak, i18n fix (v1.4.1)
- Remove auto-refresh timer from Random Albums (caused 100ms progress
  interval + 30 concurrent fetches every 30s, eventually freezing the app)
- Limit concurrent image fetches to 5 (was unbounded)
- Cap in-memory object URL cache at 150 entries with revokeObjectURL eviction
- Add cancellation flag to useCachedUrl to prevent setState on unmounted components
- Fix hardcoded "Neueste" page title in New Releases (now uses i18n)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 19:09:31 +01:00
Psychotoxical d3ffa30bf5 feat: statistics upgrade, playlists redesign, artist cards, and UX improvements (v1.4.0)
- Statistics page: library stat cards, recently played, most played, highest rated, genre chart
- Playlists page: list layout with sort (Name/Tracks/Duration) and filter input
- Favorites songs: full tracklist layout with artist column, context menu, enqueue-all button
- AlbumDetail: extracted AlbumHeader and AlbumTrackList components
- Artist cards: square cover, same sizing as album cards (clamp 140-180px)
- Random Albums: removed renderKey remount, added loadingRef guard, fixed manual refresh race
- Context menu: "Go to Album" option for song and queue-item types
- Queue panel meta box: artist → artist page, album → album page, removed year
- Random Mix: hover persistence via .context-active class while context menu is open
- i18n: "Warteschlange" consistently used for queue in German

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:36:58 +01:00
Psychotoxical f666f84479 chore: switch license from MIT to GPL v3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:33:02 +01:00
Psychotoxical c91fdd7e1d feat: Waveform seekbar, MilkDrop visualizer, EQ bars, in-app browser, and UX improvements (v1.3.0)
- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern)
- MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions
- Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix
- Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser)
- Hero/Discover deduplication: single fetch of 20 random albums split between sections
- Update checker: runs every 10 minutes during runtime; version shown without v-prefix
- Settings version: now read from package.json instead of hardcoded
- Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling
- Bump version to 1.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:24:00 +01:00
Psychotoxical 9bdd433a4b ci: upsert release — reuse existing release if tag already exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:14:22 +01:00
Psychotoxical a5fd70d3eb ci: add libasound2-dev for rodio/ALSA on Linux build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:12:44 +01:00
Psychotoxical 744775d3a1 feat: Rust audio engine, genre mix, queue shuffle, and UX improvements (v1.2.0)
### Audio Engine
- Replace Howler.js with native Rust/rodio backend (src-tauri/src/audio.rs)
- audio_play/pause/resume/stop/seek/set_volume Tauri commands
- Generation counter cancels stale in-flight downloads on skip
- Wall-clock position tracking; audio:ended after 2 ticks near end

### Playback Persistence
- Persist currentTrack, queue, queueIndex, currentTime to localStorage
- Cold-start resume: play + seek to saved position on app restart
- Position priority: server queue > localStorage

### Random Mix
- Genre blacklist: excludeAudiobooks toggle + custom chip-based blacklist
- Clickable genre chips in tracklist to blacklist on the fly
- Super Genre Mix: 9 genres, progressive rendering, Load 10 more
- Promise.allSettled + 45s timeout for large Metal/Rock libraries
- Two-column filter panel at top of page

### Queue & UI
- Shuffle button in queue header (Fisher-Yates, keeps current track at 0)
- LiveSearch arrow key / Enter / Escape keyboard navigation
- Multi-line tooltip support (data-tooltip-wrap attribute)
- Update link uses Tauri shell open() to launch system browser
- Sidebar min-width 180px → 200px (fixes Psysonic title clipping)
- Tooltip z-index fix: main-content z-index:1 over queue panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:06:03 +01:00
Psychotoxical 9b1f3b019f chore: Bump version to 1.0.12, update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:42:41 +01:00
Psychotoxical baa701dd74 fix: Playback stability, seek stop bug, and UI polish
- playerStore: Guard onend against spurious 'ended' events fired by
  WebKit/GStreamer immediately after a direct audioNode.currentTime seek.
  Root cause of the deterministic "second click on progress bar stops song"
  bug: a lastSeekAt timestamp + position check now silently drops any
  'ended' event that fires within 1 s of a seek if the playhead isn't
  actually near the track end.

- playerStore: Debounce togglePlay with a 300 ms lock to prevent rapid
  double-clicks from issuing pause→play before GStreamer has finished its
  state transition, which caused a multi-second pipeline hang.

- NowPlayingDropdown: Clicking a Live entry now navigates to the album page.

- QueuePanel: DnD drop target index now calculated from clientY position
  at drop time instead of refs, eliminating the dragend-before-drop race
  on macOS WKWebView and Windows WebView2.

- styles: Increased Hero background blur for a more immersive look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:41:02 +01:00
Psychotoxical 1e599d9636 feat: Search results page, gapless removal, UI polish (v1.0.11)
- Add full search results page (/search?q=) with artist, album, song sections
- Fix search results column alignment (fixed-width Format column eliminates per-grid auto-sizing variance)
- Remove experimental gapless playback (caused song skipping and beginning cutoffs)
- Add known limitations to README and CHANGELOG (seeking, DnD reliability)
- Update GitHub Actions to Node.js 24 (checkout@v5, setup-node@v5)
- Add align-items: center to tracklist-header CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:33:58 +01:00
Psychotoxical 623a6a4a54 feat: UI polish, DnD fix, navigation, and installer improvements (v1.0.10)
- Fix queue DnD on macOS/Windows: delay ref cleanup in onDragEnd so onDropQueue
  reads correct source/destination before dragend clears them
- Active track highlighting in album tracklist (pulsing accent bg + play icon)
- Marquee scrolling for long titles in Fullscreen Player
- Clickable artist/album in Player Bar and Queue now-playing strip
- Tracklist: format column moved after duration, auto width, kHz/filesize removed
- Settings dropdowns: visible border (base bg + overlay0 border)
- Sidebar: fixed responsive width via clamp(), removed drag-to-resize
- Favorites icon: HandMetal → Star in Random Mix page
- Linux app menu category set to Multimedia (AudioVideo)
- Windows MSI: stable upgradeCode for in-place upgrades
- Bundle: category/description fields at correct config level
- About: Claude Code credit, fixed German MIT licence wording

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:36:03 +01:00
Psychotoxical 32571a2986 feat: Gapless playback, seek recovery, buffered indicator, and UI polish (v1.0.9)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 23:54:32 +01:00
Psychotoxical c9b4bc091e feat: Ambient Stage, Queue DnD overhaul, and GStreamer fixes (v1.0.8) 2026-03-13 21:57:07 +01:00
Psychotoxical 409c20a79e Update README.md 2026-03-13 20:13:57 +01:00
Psychotoxical 3384db76cd docs: remove Security section from README 2026-03-13 20:11:53 +01:00
Psychotoxical e889a76f5f docs: update README with new features and known limitations 2026-03-13 20:11:00 +01:00
Psychotoxical eb011bdfdf Update README.md 2026-03-13 20:09:07 +01:00
Psychotoxical 1d23b21f6f Update README.md 2026-03-13 20:08:29 +01:00
Psychotoxical 5528123193 feat: Update Notification system and UI refinements (v1.0.7) 2026-03-13 19:31:44 +01:00
Psychotoxical 85823ff4c4 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:32 +01:00
Psychotoxical e36a81f847 Update README.md 2026-03-12 22:30:41 +01:00
Psychotoxical f8c45efd2b feat: IndexedDB image caching, initial-avatars, and discovery improvements (v1.0.5) 2026-03-12 22:28:25 +01:00
Psychotoxical 7e0cffc892 feat: album download support and GPU compatibility fixes (v1.0.4) 2026-03-12 20:04:28 +01:00
Psychotoxical 04773e83f7 chore: prepare release v1.0.3 2026-03-12 18:36:36 +01:00
Psychotoxical 45a220989f chore: bump version to 1.0.3 2026-03-11 23:21:11 +01:00
Psychotoxical 8a5cca799b ci: add libunwind-dev to release workflow 2026-03-11 23:16:11 +01:00
Psychotoxical ee49258c4a chore: release v1.0.2 - fix GStreamer bundling for Linux 2026-03-11 23:12:00 +01:00
Psychotoxical df9334f844 docs: update README with project attribution 2026-03-11 22:06:51 +01:00
Psychotoxical 6456b3e561 chore: prepare release v1.0.1 and ignore CLAUDE.md 2026-03-11 21:56:26 +01:00
Psychotoxical 730eb877ea docs: update readme for v1.0.0 initial release 2026-03-09 20:17:47 +01:00
Psychotoxical 8e28b349e7 docs: fresh changelog for initial public release v1.0.0 2026-03-09 20:09:49 +01:00
Psychotoxical e5ac5b775f chore: release v1.0.0 2026-03-09 20:07:30 +01:00
Psychotoxical 244435cdf1 chore: bump version to 0.1.2 and fix tauri capability build error 2026-03-09 19:52:08 +01:00
Psychotoxical bf0fdc9dd6 Update README.md 2026-03-09 19:43:30 +01:00
113 changed files with 7596 additions and 2259 deletions
+105 -19
View File
@@ -6,7 +6,72 @@ on:
workflow_dispatch:
jobs:
release:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
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,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body,
draft: false,
prerelease: false
});
return data.id;
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
@@ -17,41 +82,62 @@ jobs:
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install npm dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-22.04'
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install npm dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
- name: build
run: npm run tauri:build -- --bundles deb,rpm
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: app-v__VERSION__
releaseName: 'Psysonic v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: false
prerelease: false
args: ${{ matrix.settings.args }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
+3
View File
@@ -26,3 +26,6 @@ dist-ssr
# Tauri
src-tauri/target/
# Documentation
CLAUDE.md
+378 -16
View File
@@ -5,22 +5,384 @@ 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).
## [0.1.1] - Beta Hotfix
### Fixed
- **App Termination Bug**: Fixed an issue where the application would hang in the background when the close button was clicked due to default Tauri tray icon handling. The `exit_app` event is now correctly triggered.
## [0.1.0] - Initial Release
## [1.4.4] - 2026-03-17
### Added
- **Core Architecture**: Full transition to Tauri v2 with React front-end.
- **Subsonic API Integration**: Complete support for browsing albums, artists, playlists, and fetching streams.
- **Theming System**: Implementation of the Catppuccin Mocha (Dark) and Latte (Light) design systems with dynamic CSS variables.
- **Internationalization (i18n)**: Multi-language support structure established with English and German translations out of the box.
- **Player & Queue**: Fully functional persistent queue with drag-and-drop support, repeat modes, and volume state retention.
- **Live "Now Playing"**: Real-time dropdown to view active streams on the connected server.
- **Settings & Network**: Dual URL configuration (LAN/External) with ping-testing capabilities.
- **Full-Screen Player**: Immersive full-screen mode showing massive album art and track metadata.
- **Continuous Integration**: Automated GitHub Actions to compile native binaries for Linux, macOS, and Windows.
*Initial public release repository setup.*
#### 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
- **Extended Themes**: Selection expanded to 8 themes, including the complete Nord series (Nord, Snowstorm, Frost, Aurora).
- **Light Theme Support**: Enhanced readability for Hero and Fullscreen Player components when using light themes (Latte, Snowstorm).
### Fixed
- **Linux/Wayland Compatibility**: Fixed immediate crash on Wayland environments by forcing X11 backend for the AppImage.
- **Playback Stability**: Introduced seek debouncing to prevent audio stalls on Linux/GStreamer.
- **Windows Integration**: Improved drag-and-drop compatibility for systems using WebView2.
## [1.0.5] - 2026-03-12
### Added
- **Image Caching**: Integrated IndexedDB-based image caching for cover art and artist images, providing significantly faster loading times for frequently accessed items.
- **Improved Artist Discovery**: Faster scrolling in the Artists list using color-coded initial-based avatars for quick visual identification.
- **Random Albums**: New discovery page for exploring your library with random album selections.
- **Help & Documentation**: Added a dedicated help page for better user onboarding.
### Changed
- **Optimized UI**: Instant "Now Playing" status updates via local state filtering for a more responsive experience.
- **Enhanced Data Flow**: General performance improvements in server communication and state management.
## [1.0.4] - 2026-03-12
### Added
- **Album Downloads**: Support for downloading entire albums with real-time progress tracking.
### Fixed
- **Linux GPU Compatibility**: Patched AppImage to disable DMABUF renderer, fixing EGL/GPU crashes on older hardware.
- **CI/CD Reliability**: Optimized release workflow with split jobs for better stability across platforms.
## [1.0.3] - 2026-03-12
### Fixed
- **CI/CD Build**: Resolved build conflicts on Ubuntu 22.04 by removing redundant dev packages (`libunwind-dev`, gstreamer dev).
- **Linux AppImage**: Configured GStreamer bundling and verified runtime environment settings.
## [1.0.2] - 2026-03-11
### Fixed
- **Linux AppImage**: Integrated GStreamer bundling fix in CI/CD workflow.
- **CI/CD Reliability**: Set `APPIMAGE_EXTRACT_AND_RUN=1` to prevent FUSE-related issues.
## [1.0.1] - 2026-03-11
### Fixed
- **Optimized Codebase**: Integrated core fixes and performance improvements.
- **Improved Multi-Server Support**: Fixed edge cases in server switching and credential management.
- **Enhanced Security**: Switched to `crypto.getRandomValues()` for more robust auth salt generation.
- **Connection Reliability**: Added pre-verification for server connections to prevent state synchronization issues.
- **Linux Compatibility**: Applied workarounds for WebKitGTK compositing issues on Linux.
### Changed
- Repository maintenance and preparation for the 1.0.1 release.
## [1.0.0] - 2026-03-09
### Added
- **Initial Public Release**: The first stable release of Psysonic.
- **Subsonic/Navidrome API**: Full integration for browsing library, artists, albums, and playlists.
- **Audio Playback**: Modern audio engine powered by Howler.js with support for various codecs.
- **Queue Management**: Persistent play queue with drag-and-drop reordering and server-side synchronization.
- **Secured Credentials**: Industry-standard security using Tauri's encrypted store for authentication tokens.
- **Design System**: Premium aesthetics based on the Catppuccin palette (Mocha & Latte themes).
- **Multi-Language**: Full localization support for English and German.
- **Fullscreen Mode**: Dedicated immersive player view with high-res album art.
- **Last.fm Scrobbling**: Built-in support for track scrobbling to Last.fm via Navidrome.
- **System Integration**: Native tray icon support, minimize-to-tray, and global media key handling.
- **Intelligent Networking**: Automatic or manual switching between LAN (Local) and External (Internet) addresses.
- **Live Now Playing**: Real-time view of what other users or players are streaming on your server.
- **Search**: Fast, real-time search for songs, albums, and artists.
### Security
- **Hardened Sandbox**: Restricted filesystem permissions to only necessary download/cache directories.
- **API Lockdown**: Disabled global Tauri objects to mitigate XSS risks.
- **Credential Storage**: Replaced insecure `localStorage` with a native encrypted store.
### Fixed
- Fixed a memory leak in the track prefetching engine.
- Improved Error handling for unstable Subsonic server responses.
+16 -17
View File
@@ -1,21 +1,20 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 Psychotoxical
Copyright (C) 2026 Psychotoxical
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The full license text is available at:
https://www.gnu.org/licenses/gpl-3.0.txt
+25 -15
View File
@@ -5,40 +5,48 @@
<p>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
</p>
</div>
---
> [!WARNING]
> **Beta Release (v0.1.0):** This is the very first public release. While fully usable, you might encounter bugs. Additionally, the English translation is currently incomplete in some areas.
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.
![Psysonic Screenshot](public/screenshot.png)
## ✨ Features
- 🎨 **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, with the architecture built to easily support more languages.
- 🎨 **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.
- 📻 **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.
- 💾 **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.
- 🖥 **Cross-Platform**: Available natively for Windows, macOS, and Linux.
- 💾 **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.
- 🔗 **In-App Browser**: Last.fm and Wikipedia links on artist pages open in a dedicated native window — no need to leave the app.
- 🔄 **Update Notifications**: Built-in update checker (on startup + every 10 minutes) that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
## 📥 Installation
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` or `.msi`
- **macOS**: `.dmg`
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux**: `.AppImage` or `.deb`
## 🚀 Getting Started
@@ -54,8 +62,8 @@ If you want to build Psysonic from source or contribute to the project:
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/)
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v1/guides/getting-started/prerequisites)).
- [Rust](https://www.rust-lang.org/) (v1.75+)
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
### Setup
@@ -86,4 +94,6 @@ Contributions are completely welcome! Whether it is translating the app into a n
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

+61 -17
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "0.1.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "0.1.0",
"version": "1.3.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
@@ -16,7 +16,8 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -28,7 +29,6 @@
},
"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",
@@ -1574,13 +1574,6 @@
"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",
@@ -1664,6 +1657,16 @@
"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",
@@ -1711,6 +1714,27 @@
"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",
@@ -1773,6 +1797,14 @@
"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",
@@ -1830,6 +1862,12 @@
"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",
@@ -2110,12 +2148,6 @@
"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",
@@ -2194,6 +2226,12 @@
"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",
@@ -2448,6 +2486,12 @@
"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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "0.1.0",
"version": "1.4.4",
"private": true,
"scripts": {
"dev": "vite",
@@ -19,7 +19,8 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -31,7 +32,6 @@
},
"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",
+4
View File
@@ -0,0 +1,4 @@
src/
pkg/
*.tar.zst
*.tar.gz
+65
View File
@@ -0,0 +1,65 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
pkgname=psysonic
pkgver=1.4.3
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
url="https://github.com/Psychotoxical/psysonic"
license=('GPL-3.0-only')
depends=(
'webkit2gtk-4.1'
'gtk3'
'libayatana-appindicator'
'openssl'
'alsa-lib'
)
makedepends=(
'npm'
'rust'
'cargo'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "psysonic-app-v$pkgver"
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
npm install
npm run tauri:build -- --no-bundle
}
package() {
cd "psysonic-app-v$pkgver"
# Binary (in /usr/lib to make room for the wrapper)
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
#!/bin/sh
export GDK_BACKEND=x11
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec /usr/lib/psysonic/psysonic "\$@"
EOF
# Desktop entry
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
[Desktop Entry]
Name=Psysonic
Comment=Desktop music player for Subsonic API-compatible servers
Exec=psysonic
Icon=psysonic
Terminal=false
Type=Application
Categories=AudioVideo;Audio;Music;Player;
EOF
# Icons
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 82 KiB

+840 -22
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "0.1.0"
version = "1.4.4"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -30,3 +30,6 @@ 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"] }
+6 -4
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://schema.tauri.app/config/2/capability.json",
"$schema": "../gen/schemas/capability-schema.json",
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
@@ -7,7 +7,7 @@
"permissions": [
"core:default",
"shell:default",
"shell:allow-open",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
@@ -22,11 +22,13 @@
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:app:allow-exit"
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-create",
"core:webview:allow-create-webview-window"
]
}
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","core:window:allow-set-title"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window"],"platforms":["linux","macOS","windows"]}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+322
View File
@@ -0,0 +1,322 @@
use std::io::Cursor;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use rodio::{Decoder, Sink, Source};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
// ─── Debug logger ─────────────────────────────────────────────────────────────
// ─── Engine state (registered as Tauri managed state) ────────────────────────
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 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>,
}
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,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default(),
};
(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,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Claim this generation — any in-flight progress task with the old gen will exit.
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Stop existing playback immediately.
{
let mut cur = state.current.lock().unwrap();
if let Some(sink) = cur.sink.take() {
sink.stop();
}
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
cur.duration_secs = duration_hint;
}
// ── Download ──────────────────────────────────────────────────────────────
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
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);
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
// Bail if superseded while downloading.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
// ── Decode ────────────────────────────────────────────────────────────────
let data: Vec<u8> = bytes.into();
// Trust the Subsonic API duration_hint as the primary source.
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
// 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(());
}
// ── Create sink and start playback ────────────────────────────────────────
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
sink.set_volume(volume.clamp(0.0, 1.0));
sink.append(decoder);
{
let mut cur = state.current.lock().unwrap();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
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 - 1.0 s, which:
// • avoids false positives from seeking very close to the end
// • fires roughly 0.51 s before the last sample, giving the frontend
// enough time to queue the next download.
let gen_counter = state.generation.clone();
let current_arc = state.current.clone();
let app_clone = app.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;
}
if dur > 1.0 && pos >= dur - 1.0 {
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 cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
sink.set_volume(volume.clamp(0.0, 1.0));
}
}
+15 -1
View File
@@ -1,6 +1,8 @@
// 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},
@@ -18,7 +20,10 @@ fn exit_app(app_handle: tauri::AppHandle) {
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
tauri::Builder::default()
.manage(audio_engine)
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -107,7 +112,16 @@ pub fn run() {
let _ = window.emit("window:close-requested", ());
}
})
.invoke_handler(tauri::generate_handler![greet, exit_app])
.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,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
}
+18 -5
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "0.1.0",
"version": "1.4.4",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -10,7 +10,7 @@
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"withGlobalTauri": false,
"windows": [
{
"title": "Psysonic",
@@ -22,7 +22,8 @@
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true
"visible": true,
"dragDropEnabled": false
}
],
"security": {
@@ -31,7 +32,6 @@
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": false,
"menuOnLeftClick": false,
"title": "Psysonic",
"tooltip": "Psysonic"
}
@@ -45,6 +45,19 @@
"icons/128x128@2x.png",
"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"
}
}
}
}
+37 -26
View File
@@ -3,6 +3,8 @@ 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';
@@ -21,22 +23,27 @@ import AlbumDetail from './pages/AlbumDetail';
import LabelAlbums from './pages/LabelAlbums';
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 FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn } = useAuthStore();
if (!isLoggedIn) return <Navigate to="/login" replace />;
const { isLoggedIn, servers, activeServerId } = useAuthStore();
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
return <>{children}</>;
}
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);
@@ -63,12 +70,10 @@ 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(300);
const [isDraggingSidebar, setIsDraggingSidebar] = useState(false);
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
useEffect(() => {
@@ -76,24 +81,18 @@ function AppShell() {
}, [isSidebarCollapsed]);
const handleMouseMove = useCallback((e: MouseEvent) => {
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.
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingSidebar, isDraggingQueue]);
}, [isDraggingQueue]);
const handleMouseUp = useCallback(() => {
setIsDraggingSidebar(false);
setIsDraggingQueue(false);
}, []);
useEffect(() => {
if (isDraggingSidebar || isDraggingQueue) {
if (isDraggingQueue) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
@@ -109,13 +108,13 @@ function AppShell() {
window.removeEventListener('mouseup', handleMouseUp);
document.body.classList.remove('is-dragging');
};
}, [isDraggingSidebar, isDraggingQueue, handleMouseMove, handleMouseUp]);
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
return (
<div
className="app-shell"
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : `${sidebarWidth}px`,
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
@@ -124,24 +123,24 @@ 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" />
<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 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/albums" element={<Albums />} />
<Route path="/random-albums" element={<RandomAlbums />} />
<Route path="/album/:id" element={<AlbumDetail />} />
<Route path="/artists" element={<Artists />} />
<Route path="/artist/:id" element={<ArtistDetail />} />
@@ -150,8 +149,10 @@ 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="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
</Routes>
</div>
</main>
@@ -180,9 +181,15 @@ function TauriEventBridge() {
const previous = usePlayerStore(s => s.previous);
const { minimizeToTray } = useAuthStore();
// Spacebar → play/pause (ignore when focus is in an input)
// Spacebar → play/pause, F11 → window fullscreen
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;
@@ -227,6 +234,10 @@ export default function App() {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
return initAudioListeners();
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
+85 -52
View File
@@ -2,31 +2,38 @@ import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Token Auth ───────────────────────────────────────────────
function getAuthParams(username: string, password: string) {
const salt = Math.random().toString(36).substring(2, 10);
const salt = secureRandomSalt();
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
}
function getClient() {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const params = getAuthParams(username, password);
return {
baseUrl: `${baseUrl}/rest`,
params,
};
if (!baseUrl) throw new Error('No server configured');
const params = getAuthParams(server?.username ?? '', server?.password ?? '');
return { baseUrl: `${baseUrl}/rest`, params };
}
async function api<T>(endpoint: string, extra: Record<string, unknown> = {}): Promise<T> {
async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, timeout = 15000): Promise<T> {
const { baseUrl, params } = getClient();
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra },
paramsSerializer: { indexes: null }
paramsSerializer: { indexes: null },
timeout,
});
const data = resp.data['subsonic-response'];
const data = resp.data?.['subsonic-response'];
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
return data as T;
}
@@ -52,6 +59,7 @@ export interface SubsonicSong {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
track?: number;
discNumber?: number;
@@ -59,13 +67,15 @@ export interface SubsonicSong {
year?: number;
userRating?: number;
// Audio technical info
bitRate?: number; // kbps
suffix?: string; // mp3, flac, opus…
contentType?: string; // audio/mpeg, audio/flac…
size?: number; // bytes
samplingRate?: number; // Hz
bitRate?: number;
suffix?: string;
contentType?: string;
size?: number;
samplingRate?: number;
channelCount?: number;
starred?: string;
genre?: string;
path?: string;
}
export interface SubsonicPlaylist {
@@ -95,7 +105,7 @@ export interface SubsonicArtist {
}
export interface SubsonicGenre {
value: string; // The genre name is returned as "value" by subsonic
value: string;
songCount: number;
albumCount: number;
}
@@ -119,6 +129,24 @@ export async function ping(): Promise<boolean> {
}
}
/** Test a connection with explicit credentials — does NOT depend on store state. */
export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise<boolean> {
try {
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
return data?.status === 'ok';
} catch {
return false;
}
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', size });
return data.albumList2?.album ?? [];
@@ -134,8 +162,10 @@ export async function getAlbumList(
return data.albumList2?.album ?? [];
}
export async function getRandomSongs(size = 50): Promise<SubsonicSong[]> {
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', { size });
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);
return data.randomSongs?.song ?? [];
}
@@ -205,13 +235,8 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view');
const r = data.starred2 ?? {};
return {
artists: r.artist ?? [],
albums: r.album ?? [],
songs: r.song ?? [],
};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function star(id: string, type: 'song' | 'album' | 'artist' = 'album'): Promise<void> {
@@ -270,33 +295,50 @@ export async function reportNowPlaying(id: string): Promise<void> {
// ─── Stream URL ───────────────────────────────────────────────
export function buildStreamUrl(id: string): string {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const { u, t, s, v, c, f } = (() => {
const salt = Math.random().toString(36).substring(2, 10);
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
})();
const p = new URLSearchParams({ id, u, t, s, v, c, f });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
/** Stable cache key for cover art — does not include ephemeral auth params. */
export function coverArtCacheKey(id: string, size = 256): string {
const server = useAuthStore.getState().getActiveServer();
return `${server?.id ?? '_'}:cover:${id}:${size}`;
}
export function buildCoverArtUrl(id: string, size = 256): string {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const salt = Math.random().toString(36).substring(2, 10);
const token = md5(password + salt);
const p = new URLSearchParams({ id, size: String(size), u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id, size: String(size),
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
}
export function buildDownloadUrl(id: string): string {
const { getBaseUrl, username, password } = useAuthStore.getState();
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const salt = Math.random().toString(36).substring(2, 10);
const token = md5(password + salt);
const p = new URLSearchParams({ id, u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' });
const salt = secureRandomSalt();
const token = md5((server?.password ?? '') + salt);
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/download.view?${p.toString()}`;
}
@@ -307,7 +349,6 @@ export async function getPlaylists(): Promise<SubsonicPlaylist[]> {
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
// Songs inside a playlist are typically in the 'entry' array
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
@@ -330,11 +371,7 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num
try {
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
const pq = data.playQueue;
return {
current: pq?.current,
position: pq?.position,
songs: pq?.entry ?? []
};
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
} catch {
return { songs: [] };
}
@@ -342,12 +379,9 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num
export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise<void> {
const params: Record<string, unknown> = {};
if (songIds.length > 0) {
params.id = songIds;
}
if (songIds.length > 0) params.id = songIds;
if (current !== undefined) params.current = current;
if (position !== undefined) params.position = position;
await api('savePlayQueue.view', params);
}
@@ -355,7 +389,6 @@ export async function savePlayQueue(songIds: string[], current?: string, positio
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
try {
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying[] } | '' }>('getNowPlaying.view');
// Navidrome might return an empty string or empty object if nobody is playing
if (!data.nowPlaying || typeof data.nowPlaying === 'string') return [];
return data.nowPlaying.entry ?? [];
} catch {
+4 -3
View File
@@ -1,8 +1,9 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Play } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl } from '../api/subsonic';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from './CachedImage';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -28,7 +29,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
@@ -37,7 +38,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
>
<div className="album-card-cover">
{coverUrl ? (
<img src={coverUrl} alt={`${album.name} Cover`} loading="lazy" />
<CachedImage src={coverUrl} cacheKey={coverArtCacheKey(album.coverArt!, 300)} alt={`${album.name} Cover`} loading="lazy" />
) : (
<div className="album-card-cover-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import CachedImage from './CachedImage';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatSize(bytes?: number): string {
if (!bytes) return '';
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (
name.startsWith('on') ||
(name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) ||
(name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
}
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
const { t } = useTranslation();
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
</div>
</div>
);
}
interface AlbumInfo {
id: string;
name: string;
artist: string;
artistId: string;
year?: number;
genre?: string;
coverArt?: string;
recordLabel?: string;
}
interface AlbumHeaderProps {
info: AlbumInfo;
songs: SubsonicSong[];
coverUrl: string;
coverKey: string;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onBio: () => void;
onCloseBio: () => void;
}
export default function AlbumHeader({
info,
songs,
coverUrl,
coverKey,
resolvedCoverUrl,
isStarred,
downloadProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onPlayAll,
onEnqueueAll,
onBio,
onCloseBio,
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
<div className="album-detail-header">
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
)}
<div className="album-detail-overlay" aria-hidden="true" />
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
{coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('common.album')}</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
onClick={() => navigate(`/artist/${info.artistId}`)}
>
{info.artist}
</button>
</p>
<div className="album-detail-info">
{info.year && <span>{info.year}</span>}
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
</button>
</>
)}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { SubsonicAlbum } from '../api/subsonic';
import AlbumCard from './AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -86,7 +88,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function AlbumRow({ title, albums, moreLink, moreText, onLoadMore
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+186
View File
@@ -0,0 +1,186 @@
import React from 'react';
import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const { t } = useTranslation();
const [hover, setHover] = React.useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
hoveredSongId: string | null;
setHoveredSongId: (id: string | null) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
}
export default function AlbumTrackList({
songs,
hasVariousArtists,
currentTrack,
isPlaying,
hoveredSongId,
setHoveredSongId,
ratings,
starredSongs,
onPlaySong,
onRate,
onToggleSongStar,
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = discNums.length > 1;
const makeTrack = (song: SubsonicSong): Track => ({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, track: song.track, year: song.year,
bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
});
return (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span>
CD {discNum}
</div>
)}
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
}}
>
<div
className="track-num"
style={{
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
}}
onClick={() => onPlaySong(song)}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
</div>
))}
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+15 -16
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import CachedImage from './CachedImage';
interface Props {
artist: SubsonicArtist;
@@ -12,16 +13,14 @@ export default function ArtistCardLocal({ artist }: Props) {
const coverId = artist.coverArt || artist.id;
return (
<div
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
>
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card-avatar">
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 200)}
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
@@ -31,14 +30,14 @@ export default function ArtistCardLocal({ artist }: Props) {
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-name" data-tooltip={artist.name}>
{artist.name}
<div className="artist-card-info">
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
</span>
)}
</div>
{typeof artist.albumCount === 'number' && (
<div className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
</div>
)}
</div>
);
}
+7 -5
View File
@@ -3,6 +3,7 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
@@ -13,6 +14,7 @@ interface Props {
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -57,10 +59,10 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
if (artists.length === 0) return null;
return (
<section className="artist-row-section">
<div className="artist-row-header">
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="artist-row-nav">
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
@@ -86,7 +88,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>Lädt...</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
@@ -94,7 +96,7 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText || 'Alle ansehen'}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
+23
View File
@@ -0,0 +1,23 @@
import React, { useEffect, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
cacheKey: string;
}
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; };
}, [fetchUrl, cacheKey]);
return resolved || fetchUrl;
}
export default function CachedImage({ src, cacheKey, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
return <img src={resolvedSrc} {...props} />;
}
+140 -139
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
@@ -7,13 +7,23 @@ import { useAuthStore } from '../store/authStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const auth = useAuthStore();
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
// Adjusted coordinates to keep menu on screen
const [coords, setCoords] = useState({ x: 0, y: 0 });
@@ -28,37 +38,14 @@ export default function ContextMenu() {
const rect = menuRef.current.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
let finalX = contextMenu.x;
let finalY = contextMenu.y;
if (finalX + rect.width > winW) finalX = winW - rect.width - 10;
if (finalY + rect.height > winH) finalY = winH - rect.height - 10;
setCoords({ x: finalX, y: finalY });
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
if (contextMenu.isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEsc);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEsc);
};
}, [contextMenu.isOpen, closeContextMenu]);
if (!contextMenu.isOpen || !contextMenu.item) return null;
const { type, item, queueIndex } = contextMenu;
@@ -75,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, duration: s.duration, coverArt: s.coverArt, track: s.track,
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,
}));
playTrack(radioTracks[0], radioTracks);
@@ -91,139 +78,153 @@ export default function ContextMenu() {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${albumName}.zip`);
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 = `${albumName}.zip`;
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 fehlgeschlagen:', e);
console.error('Download failed:', e);
}
};
return (
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y }}
>
{(type === 'song' || type === 'album-song') && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> Direkt abspielen
</div>
<div className="context-menu-item" onClick={() => handleAction(() => {
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronRight size={14} /> Als Nächstes abspielen
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> Zur Warteschlange hinzufügen
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
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, 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={14} /> Ganzes Album einreihen
<>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y, zIndex: 999 }}
>
{(type === 'song' || type === 'album-song') && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> Song-Radio starten
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> Favorisieren
</div>
</>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => {
if (!currentTrack) {
playTrack(song, [song]);
return;
}
const currentIdx = usePlayerStore.getState().queueIndex;
const newQueue = [...queue];
newQueue.splice(currentIdx + 1, 0, song);
usePlayerStore.setState({ queue: newQueue });
})}>
<ChevronRight size={14} /> {t('contextMenu.playNext')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{type === 'album-song' && (
<div className="context-menu-item" onClick={() => handleAction(async () => {
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,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</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>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
</>
);
})()}
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => {
// we don't have tracks here immediately, so we'd navigate or fetch. For now, navigate.
navigate(`/album/${album.id}`);
})}>
<Play size={14} /> Album öffnen
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> Zum Künstler
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> Album favorisieren
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> Herunterladen (ZIP)
</div>
</>
);
})()}
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
</>
);
})()}
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> Künstler-Radio starten
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> Künstler favorisieren
</div>
</>
);
})()}
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
</div>
</>
);
})()}
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> Direkt abspielen
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
Diesen Song entfernen
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> Song-Radio starten
</div>
</>
);
})()}
</div>
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
if (queueIndex !== undefined) removeTrack(queueIndex);
})}>
{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>
</>
);
})()}
</div>
</>
);
}
+189 -109
View File
@@ -1,10 +1,13 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
ChevronDown, Repeat, Repeat1, Square, Music
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } 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';
@@ -13,9 +16,45 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
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 ───────────────────────────────────────────
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 }] : []
);
@@ -24,15 +63,10 @@ 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);
@@ -53,67 +87,92 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
);
});
// ─── Isolated progress sub-component (re-renders every tick, nothing else does) ───
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
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 handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-bottom">
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
aria-label="Songfortschritt"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'}>
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
);
});
// ─── Main component ────────────────────────────────────────────────────────────
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const { t } = useTranslation();
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 duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
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 resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
// 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);
@@ -121,96 +180,117 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
}, [onClose]);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label="Vollbild-Player">
{/* Crossfading blurred background */}
<FsBg url={coverUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Close button */}
<button className="fs-close" onClick={onClose} aria-label="Vollbild schließen" data-tooltip="Schließen (Esc)">
{/* 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')}>
<ChevronDown size={28} />
</button>
{/* Main layout: cover left, upcoming right */}
<div className="fs-layout">
{/* Left column: cover only */}
<div className="fs-left">
<div className="fs-cover-wrap">
{coverUrl ? (
<img src={coverUrl} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
</div>
</div>
{/* Right column: upcoming tracks */}
{upcoming.length > 0 && (
<div className="fs-right">
<h2 className="fs-upcoming-title">Nächste Titel</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 ? (
<img src={buildCoverArtUrl(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>
)}
</div>
{/* Bottom: meta + progress + controls — centered across full width */}
<div className="fs-footer">
<div className="fs-footer-main">
<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(' · ')}
{/* 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}
</span>
)}
</div>
<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} />
</button>
</>
)}
<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>
{/* Progress bar — isolated sub-component */}
<FsProgress duration={duration} />
{/* 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>
{/* Transport controls */}
<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} data-tooltip="Stop">
<Square size={20} fill="currentColor" />
<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="Vorheriger Titel">
<SkipBack size={28} />
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={20} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label="Nächster Titel">
<SkipForward size={28} />
<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={`Wiederholen: ${repeatMode === 'off' ? 'Aus' : repeatMode === 'all' ? 'Alle' : 'Einen'}`}
aria-label={t('player.repeat')}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
</div>
</div>
);
+115 -30
View File
@@ -1,47 +1,118 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, getAlbum } from '../api/subsonic';
import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
export default function Hero() {
const [album, setAlbum] = useState<SubsonicAlbum | null>(null);
const navigate = useNavigate();
const INTERVAL_MS = 10000;
// Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(1);
useEffect(() => {
let cancelled = false;
getRandomAlbums(1).then(albums => {
if (!cancelled && albums[0]) setAlbum(albums[0]);
}).catch(() => {});
return () => { cancelled = true; };
if (!url) return;
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<>
{layers.map(layer => (
<div
key={layer.id}
className="hero-bg"
style={{
backgroundImage: `url(${layer.url})`,
opacity: layer.visible ? 1 : 0,
filter: layer.visible ? 'blur(0px)' : 'blur(18px)',
}}
aria-hidden="true"
/>
))}
</>
);
}
interface HeroProps {
albums?: SubsonicAlbum[];
}
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0);
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) => {
if (timerRef.current) clearInterval(timerRef.current);
if (len <= 1) return;
timerRef.current = setInterval(() => {
setActiveIdx(prev => (prev + 1) % len);
}, INTERVAL_MS);
}, []);
if (!album) return <div className="hero-placeholder" />;
useEffect(() => {
startTimer(albums.length);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [albums.length, startTimer]);
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const goTo = useCallback((idx: number) => {
setActiveIdx(idx);
startTimer(albums.length);
}, [albums.length, startTimer]);
const album = albums[activeIdx] ?? null;
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
// Resolve cover thumbnail via cache
const coverRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const coverCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
if (!album) return <div className="hero-placeholder" />;
return (
<div
className="hero"
role="banner"
aria-label="Album des Augenblicks"
aria-label={t('hero.eyebrow')}
onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }}
>
{coverUrl && (
<div
className="hero-bg"
style={{ backgroundImage: `url(${coverUrl})` }}
aria-hidden="true"
/>
)}
<HeroBg url={resolvedBgUrl} />
<div className="hero-overlay" aria-hidden="true" />
<div className="hero-content animate-fade-in">
{coverUrl && (
<img className="hero-cover" src={coverUrl} alt={`${album.name} Cover`} />
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}>
{coverRawUrl && (
<CachedImage
className="hero-cover"
src={coverRawUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`}
/>
)}
<div className="hero-text">
<span className="hero-eyebrow">Album des Augenblicks</span>
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{album.artist}</p>
<div className="hero-meta">
@@ -54,10 +125,10 @@ export default function Hero() {
className="hero-play-btn"
id="hero-play-btn"
onClick={e => { e.stopPropagation(); navigate(`/album/${album.id}`); }}
aria-label={`Album ${album.name} abspielen`}
aria-label={`${t('hero.playAlbum')} ${album.name}`}
>
<Play size={18} fill="currentColor" />
Album abspielen
{t('hero.playAlbum')}
</button>
<button
className="btn btn-surface"
@@ -67,21 +138,35 @@ export default function Hero() {
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, duration: s.duration, coverArt: s.coverArt, track: s.track,
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,
}));
usePlayerStore.getState().enqueue(tracks);
} catch (err) { }
} catch (_) { }
}}
style={{ padding: '0 1.5rem', fontWeight: 600, fontSize: '0.95rem' }}
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
data-tooltip={t('hero.enqueueTooltip')}
>
<ListPlus size={18} />
Einreihen
{t('hero.enqueue')}
</button>
</div>
</div>
</div>
{/* Carousel dot indicators */}
{albums.length > 1 && (
<div className="hero-dots" onClick={e => e.stopPropagation()}>
{albums.map((_, i) => (
<button
key={i}
className={`hero-dot${i === activeIdx ? ' hero-dot-active' : ''}`}
onClick={() => goTo(i)}
aria-label={`Album ${i + 1}`}
/>
))}
</div>
)}
</div>
);
}
+110 -70
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -13,13 +14,16 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
}
export default function LiveSearch() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
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) => {
@@ -36,7 +40,7 @@ export default function LiveSearch() {
[]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
// Close on click outside
useEffect(() => {
@@ -49,6 +53,40 @@ 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">
@@ -63,95 +101,97 @@ export default function LiveSearch() {
id="live-search-input"
className="input live-search-field"
type="search"
placeholder="Suchen nach Künstler, Album oder Song…"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open}
autoComplete="off"
/>
{query && (
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label="Suche leeren">
<button className="live-search-clear" onClick={() => { setQuery(''); setResults(null); setOpen(false); }} aria-label={t('search.clearLabel')}>
×
</button>
)}
</div>
{open && (
<div className="live-search-dropdown" id="search-results" role="listbox">
<div className="live-search-dropdown" id="search-results" role="listbox" ref={dropdownRef}>
{!hasResults && !loading && (
<div className="search-empty">Keine Ergebnisse für {query}"</div>
<div className="search-empty">{t('search.noResults', { query })}</div>
)}
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> Künstler</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}
{(() => {
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?.albums.length ? (
<div className="search-section">
<div className="search-section-label"><Disc3 size={12} /> Alben</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?.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?.songs.length ? (
<div className="search-section">
<div className="search-section-label"><Music size={12} /> 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}
{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}
</>;
})()}
</div>
)}
</div>
+51 -28
View File
@@ -1,10 +1,18 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
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);
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -20,11 +28,16 @@ export default function NowPlayingDropdown() {
}
};
// Fetch when the dropdown is opened
// Poll in background so the badge stays current without opening the dropdown
useEffect(() => {
if (isOpen) {
fetchNowPlaying();
}
fetchNowPlaying();
const id = setInterval(fetchNowPlaying, 10000);
return () => clearInterval(id);
}, []);
// Refresh immediately when dropdown is opened
useEffect(() => {
if (isOpen) fetchNowPlaying();
}, [isOpen]);
// Click outside to close
@@ -38,33 +51,39 @@ export default function NowPlayingDropdown() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// For the current user, trust the local player state — the server keeps stale
// "now playing" entries for minutes after playback stops.
const visible = nowPlaying.filter(entry =>
entry.username === ownUsername ? isPlaying : true
);
return (
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
<button
className="btn btn-surface"
<button
className="btn btn-surface"
onClick={() => setIsOpen(!isOpen)}
data-tooltip="Wer hört was?"
data-tooltip={t('nowPlaying.tooltip')}
data-tooltip-pos="bottom"
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<Radio size={18} className={nowPlaying.length > 0 ? 'animate-pulse' : ''} style={{ color: nowPlaying.length > 0 ? 'var(--accent)' : 'inherit' }} />
<Radio size={18} className={visible.length > 0 ? 'animate-pulse' : ''} style={{ color: visible.length > 0 ? 'var(--accent)' : 'inherit' }} />
<span>Live</span>
{nowPlaying.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
borderRadius: '10px'
}}>
{nowPlaying.length}
{visible.length}
</span>
)}
</button>
{isOpen && (
<div
<div
className="glass animate-fade-in"
style={{
position: 'absolute',
@@ -83,9 +102,9 @@ export default function NowPlayingDropdown() {
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Wer hört was?</h3>
<button
onClick={fetchNowPlaying}
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={fetchNowPlaying}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
style={{ width: '28px', height: '28px', padding: 0 }}
>
@@ -93,18 +112,22 @@ export default function NowPlayingDropdown() {
</button>
</div>
{loading && nowPlaying.length === 0 ? (
{loading && visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Lädt...
{t('nowPlaying.loading')}
</div>
) : nowPlaying.length === 0 ? (
) : visible.length === 0 ? (
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
Gerade hört niemand Musik.
{t('nowPlaying.nobody')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{nowPlaying.map((stream, idx) => (
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
{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 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' }} />
@@ -118,7 +141,7 @@ export default function NowPlayingDropdown() {
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', fontSize: '11px', color: 'var(--text-muted)' }}>
<User size={10} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
{stream.minutesAgo > 0 && <span> vor {stream.minutesAgo}m</span>}
{stream.minutesAgo > 0 && <span> {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>}
</div>
</div>
</div>
+91 -96
View File
@@ -1,10 +1,14 @@
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -15,28 +19,28 @@ function formatTime(seconds: number): string {
export default function PlayerBar() {
const { t } = useTranslation();
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const navigate = useNavigate();
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
} = usePlayerStore();
const duration = currentTrack?.duration ?? 0;
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
seek(parseFloat(e.target.value));
}, [seek]);
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
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
@@ -45,11 +49,11 @@ export default function PlayerBar() {
data-tooltip={currentTrack ? t('player.openFullscreen') : undefined}
>
{currentTrack?.coverArt ? (
<img
<CachedImage
className="player-album-art"
src={buildCoverArtUrl(currentTrack.coverArt, 128)}
src={coverSrc}
cacheKey={coverKey}
alt={`${currentTrack.album} Cover`}
loading="lazy"
/>
) : (
<div className="player-album-art-placeholder">
@@ -63,97 +67,88 @@ export default function PlayerBar() {
)}
</div>
<div className="player-track-meta">
<div className="player-track-name" data-tooltip={currentTrack?.title ?? ''}>
<div
className="player-track-name"
data-tooltip={currentTrack?.title ?? ''}
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
{currentTrack?.title ?? t('player.noTitle')}
</div>
<div className="player-track-artist" data-tooltip={currentTrack?.artist ?? ''}>
<div
className="player-track-artist"
data-tooltip={currentTrack?.artist ?? ''}
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
{currentTrack?.artist ?? '—'}
</div>
</div>
</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={18} />
</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={20} /> : <Play size={20} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={18} />
</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>
<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)' }}
{/* 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')}
>
<List size={18} />
{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>
{/* Waveform Seekbar */}
<div className="player-waveform-section">
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap">
<WaveformSeek trackId={currentTrack?.id} />
</div>
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* 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>
</footer>
);
}
+127 -55
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } 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';
@@ -109,8 +110,13 @@ 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);
@@ -120,10 +126,19 @@ 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);
@@ -142,77 +157,126 @@ export default function QueuePanel() {
};
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
_dragFromIdx = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'queue_reorder',
index
}));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
// _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, dropIndex?: number) => {
const onDropQueue = async (e: React.DragEvent) => {
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 dataStr = e.dataTransfer.getData('application/json');
if (!dataStr) return;
const data = JSON.parse(dataStr);
if (data.type === 'queue_reorder') {
const fromIdx = data.index;
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (fromIdx !== undefined && fromIdx !== targetIdx) {
reorderQueue(fromIdx, targetIdx);
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;
}
}
} else 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);
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
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);
}
};
return (
<aside
className="queue-panel"
onDragOver={e => e.preventDefault()}
onDrop={e => onDropQueue(e)}
<aside
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}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
>
<div className="queue-header">
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
<div>
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
const dur = h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
return (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</div>
);
})()}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
<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>
@@ -233,15 +297,25 @@ export default function QueuePanel() {
<div className="queue-current-track">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 400)} alt="" loading="eager" />
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} 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>
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
<div className="queue-current-sub truncate" data-tooltip={currentTrack.album}>{currentTrack.album}</div>
<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>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
<div className="queue-current-tech">
@@ -259,7 +333,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">
<div className="queue-list" ref={queueListRef}>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
@@ -283,9 +357,10 @@ export default function QueuePanel() {
}
return (
<div
key={`${track.id}-${idx}`}
className={`queue-item ${isPlaying ? 'active' : ''}`}
<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' : ''}`}
onClick={() => playTrack(track, queue)}
onContextMenu={(e) => {
e.preventDefault();
@@ -293,12 +368,9 @@ export default function QueuePanel() {
}}
draggable
onDragStart={(e) => onDragStart(e, idx)}
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">
@@ -339,7 +411,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, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) {
+81 -8
View File
@@ -1,9 +1,11 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
} from 'lucide-react';
const PsysonicLogo = () => (
@@ -13,21 +15,83 @@ const PsysonicLogo = () => (
const navItems = [
{ icon: Disc3, labelKey: 'sidebar.mainstage', to: '/' },
{ icon: Radio, labelKey: 'sidebar.newReleases', to: '/new-releases' },
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Music4, labelKey: 'sidebar.allAlbums', to: '/albums' },
{ icon: Dices, labelKey: 'sidebar.randomAlbums', to: '/random-albums' },
{ icon: Users, labelKey: 'sidebar.artists', to: '/artists' },
{ icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists' },
{ icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix' },
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
];
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
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
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
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' : ''}`}>
@@ -60,15 +124,24 @@ export default function Sidebar({
))}
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed ? { marginTop: 'auto' } : undefined}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
>
<BarChart3 size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
</NavLink>
<NavLink
to="/help"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.help') : undefined}
>
<HelpCircle size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.help')}</span>}
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
+130
View File
@@ -0,0 +1,130 @@
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' }}
/>
);
}
+180
View File
@@ -0,0 +1,180 @@
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 seekFromX = (clientX: number) => {
const canvas = canvasRef.current;
if (!canvas || !trackId) return;
const rect = canvas.getBoundingClientRect();
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
};
useEffect(() => {
const up = () => { isDragging.current = false; };
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
/>
);
}
+687 -26
View File
@@ -8,6 +8,7 @@ const enTranslation = {
mainstage: 'Mainstage',
newReleases: 'New Releases',
allAlbums: 'All Albums',
randomAlbums: 'Random Albums',
artists: 'Artists',
playlists: 'Playlists',
randomMix: 'Random Mix',
@@ -15,8 +16,219 @@ const enTranslation = {
system: 'System',
statistics: 'Statistics',
settings: 'Settings',
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
},
home: {
starred: 'Personal Favorites',
recent: 'Recently Added',
mostPlayed: 'Most Played',
discover: 'Discover',
loadMore: 'Load More',
discoverMore: 'Discover More'
},
hero: {
eyebrow: 'Featured Album',
playAlbum: 'Play Album',
enqueue: 'Enqueue',
enqueueTooltip: 'Add entire album to queue',
},
search: {
placeholder: 'Search for artist, album or song…',
noResults: 'No results for "{{query}}"',
artists: 'Artists',
albums: 'Albums',
songs: 'Songs',
clearLabel: 'Clear search',
title: 'Search',
resultsFor: 'Results for "{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Who is listening?',
title: 'Who is listening?',
loading: 'Loading…',
nobody: 'Nobody is currently listening.',
minutesAgo: '{{n}}m ago',
},
contextMenu: {
playNow: 'Play Now',
playNext: 'Play Next',
addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album',
startRadio: 'Start Radio',
favorite: 'Favorite',
favoriteArtist: 'Favorite Artist',
favoriteAlbum: 'Favorite Album',
removeFromQueue: 'Remove from Queue',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
},
albumDetail: {
back: 'Back',
playAll: 'Play All',
enqueue: 'Enqueue',
enqueueTooltip: 'Add entire album to queue',
artistBio: 'Artist Bio',
download: 'Download (ZIP)',
downloading: 'Loading…',
downloadHint: 'FLAC/WAV albums are zipped server-side first — large albums may take a moment before the download starts.',
downloadHintShort: 'Server zips first — may take a moment depending on file size',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
favorite: 'Favorite',
noBio: 'No biography available.',
moreByArtist: 'More by {{artist}}',
tracksCount: '{{n}} Tracks',
goToArtist: 'Go to {{artist}}',
moreLabelAlbums: 'More albums on {{label}}',
trackTitle: 'Title',
trackArtist: 'Artist',
trackFormat: 'Format',
trackFavorite: 'Favorite',
trackRating: 'Rating',
trackDuration: 'Duration',
trackTotal: 'Total',
notFound: 'Album not found.',
bioModal: 'Artist Biography',
bioClose: 'Close',
ratingLabel: 'Rating',
},
artistDetail: {
back: 'Back',
albums: 'Albums',
album: 'Album',
playAll: 'Play All',
shuffle: 'Shuffle',
radio: 'Radio',
loading: 'Loading…',
noRadio: 'No similar tracks found for this artist.',
notFound: 'Artist not found.',
albumsBy: 'Albums by {{name}}',
topTracks: 'Top Tracks',
noAlbums: 'No albums found.',
trackTitle: 'Title',
trackAlbum: 'Album',
trackDuration: 'Duration',
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
favorite: 'Favorite',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
},
favorites: {
title: 'Favorites',
empty: "You haven't saved any favorites yet.",
artists: 'Artists',
albums: 'Albums',
songs: 'Songs',
enqueueAll: 'Add all to queue',
},
randomAlbums: {
title: 'Random Albums',
refresh: 'Refresh',
},
randomMix: {
title: 'Random Mix',
remix: 'Remix',
remixTooltip: 'Load new random songs',
playAll: 'Play All',
trackTitle: 'Title',
trackArtist: 'Artist',
trackAlbum: 'Album',
trackFavorite: 'Favorite',
trackDuration: 'Duration',
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',
loading: 'Loading playlists…',
empty: 'No playlists found.\nUse the queue to create playlists.',
play: 'Play',
deleteTooltip: 'Delete',
confirmDelete: 'Really delete playlist "{{name}}"?',
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',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Artist)',
sortNewest: 'Newest first',
sortRandom: 'Random',
},
artists: {
title: 'Artists',
search: 'Search…',
all: 'All',
gridView: 'Grid view',
listView: 'List view',
loadMore: 'Load more',
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
serverName: 'Server Name (optional)',
serverNamePlaceholder: 'My Navidrome',
serverUrl: 'Server URL',
serverUrlPlaceholder: '192.168.1.100:4533 or music.example.com',
username: 'Username',
usernamePlaceholder: 'admin',
password: 'Password',
showPassword: 'Show password',
hidePassword: 'Hide password',
connect: 'Connect',
connecting: 'Connecting…',
connected: 'Connected!',
error: 'Connection failed please check your details.',
urlRequired: 'Please enter a server URL.',
savedServers: 'Saved Servers',
addNew: 'Or add a new server',
},
common: {
albums: 'Albums',
album: 'Album',
loading: 'Loading…',
loadingMore: 'Loading…',
loadingPlaylists: 'Loading Playlists…',
noAlbums: 'No albums found.',
downloading: 'Downloading…',
downloadZip: 'Download (ZIP)',
back: 'Back',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
use: 'Use',
add: 'Add',
active: 'Active',
},
settings: {
title: 'Settings',
@@ -25,19 +237,29 @@ const enTranslation = {
languageDe: 'German',
theme: 'Theme',
appearance: 'Appearance',
connection: 'Connection',
lanIp: 'LAN IP',
externalUrl: 'External URL',
servers: 'Servers',
serverName: 'Server Name',
serverUrl: 'Server URL',
serverUsername: 'Username',
serverPassword: 'Password',
addServer: 'Add Server',
addServerTitle: 'Add New Server',
useServer: 'Use',
deleteServer: 'Delete',
noServers: 'No servers saved.',
serverActive: 'Active',
confirmDeleteServer: 'Delete server "{{name}}"?',
serverConnecting: 'Connecting…',
serverConnected: 'Connected!',
serverFailed: 'Connection failed.',
testBtn: 'Test Connection',
testingBtn: 'Testing…',
connected: 'Connected',
failed: 'Failed',
activeConn: 'Active Connection',
activeServer: 'Currently used server:',
connLocal: 'Local (LAN)',
connExternal: 'External (Internet)',
lfmTitle: 'Last.fm Scrobbling',
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the <strong>Navidrome Webplayer</strong> in your browser, go to your profile, and connect your Last.fm account.',
lfmDesc1: 'Psysonic supports server-side scrobbling directly via Navidrome. To link Last.fm, please log in once via the',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: 'in your browser, go to your profile, and connect your Last.fm account.',
lfmDesc2: 'Once that is done, Psysonic automatically forwards your currently playing songs to Navidrome, and they will appear on Last.fm.',
scrobbleEnabled: 'Scrobbling enabled',
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
@@ -50,7 +272,89 @@ const enTranslation = {
downloadsDefault: 'Default Downloads Folder',
pickFolder: 'Select',
pickFolderTitle: 'Select Download Folder',
logout: 'Logout'
logout: 'Logout',
aboutTitle: 'About Psysonic',
aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.',
aboutFeatures: 'Multi-server support · Scrobbling · Fullscreen player · Album downloads · Image caching · Catppuccin themes',
aboutLicense: 'License',
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
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',
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)',
},
help: {
title: 'Help',
s1: 'Getting Started',
q1: 'Which servers are compatible?',
a1: 'Psysonic works with any Subsonic-compatible server: Navidrome, Gonic, Subsonic, Airsonic, and others. Navidrome is the recommended choice.',
q2: 'How do I connect to my server?',
a2: 'Open Settings and click "Add Server". Enter the server URL (e.g. 192.168.1.100:4533), your username, and password. Psysonic tests the connection before saving — nothing is stored if the connection fails.',
q3: 'Can I use multiple servers?',
a3: 'Yes. You can add as many servers as you like in Settings and switch between them at any time. Only one server is active at a time.',
s2: 'Playback',
q4: 'How do I play music?',
a4: 'Double-click any track to play it. On album and artist pages, use "Play All" to start the whole album. You can also drag tracks into the queue panel.',
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.',
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?',
a8: 'Click the repeat button in the player bar to cycle through: Off → Repeat All → Repeat One.',
s3: 'Library',
q9: 'How do I download an album?',
a9: 'Open an album\'s detail page and click "Download (ZIP)". The server zips the album first — this may take a moment for large albums or lossless files (FLAC / WAV). A progress bar shows the download status.',
q10: 'How do I star / favorite tracks and albums?',
a10: 'Click the star icon on any track row or on the album header. Starred items appear in the Favorites section in the sidebar.',
q11: 'What is the hero carousel on the home page?',
a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.',
s4: 'Settings',
q12: 'How do I change the theme?',
a12: 'Settings → Theme. Choose between Catppuccin Mocha (dark) and Catppuccin Latte (light).',
q13: 'How do I change the language?',
a13: 'Settings → Language. English and German are currently supported.',
q14: 'What does "Minimize to Tray" do?',
a14: 'When enabled, clicking the × button hides Psysonic to the system tray instead of closing it. Music keeps playing. Right-click the tray icon for controls or to quit.',
q15: 'How do I set a download folder?',
a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.',
s5: 'Scrobbling',
q16: 'How does scrobbling work?',
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.',
q19: 'The connection test fails.',
a19: 'Check the URL including the port (e.g. http://192.168.1.100:4533). Make sure no firewall blocks the connection. Try http:// instead of https:// on a local network. Also verify that your username and password are correct.',
q20: 'No audio on Linux (AppImage).',
a20: 'The AppImage bundles GStreamer. If audio still doesn\'t work, try installing system GStreamer packages: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
q21: 'The app crashes or shows a black screen on old Linux hardware.',
a21: 'This is usually caused by GPU / EGL driver issues in WebKitGTK. The official AppImage already patches the launcher to disable GPU compositing. If you build from source, launch with: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
},
queue: {
title: 'Queue',
@@ -59,20 +363,47 @@ const enTranslation = {
cancel: 'Cancel',
save: 'Save',
loadPlaylist: 'Load Playlist',
loading: 'Loading...',
loading: 'Loading',
noPlaylists: 'No playlists found.',
load: 'Load',
delete: 'Delete',
deleteConfirm: 'Delete playlist "{{name}}"?',
clear: 'Clear',
shuffle: 'Shuffle queue',
hide: 'Hide',
close: 'Close',
nextTracks: 'Next Tracks',
emptyQueue: 'The queue is empty.'
emptyQueue: 'The queue is empty.',
trackSingular: 'track',
trackPlural: 'tracks',
},
statistics: {
title: 'Statistics',
recentlyPlayed: 'Recently Played',
mostPlayed: 'Most Played Albums',
highestRated: 'Highest Rated Albums',
genreDistribution: 'Genre Distribution (Top 20)',
loadMore: 'Load more',
statArtists: 'Artists',
statAlbums: 'Albums',
statSongs: 'Songs',
statGenres: 'Genres',
genreSongs: '{{count}} Songs',
genreAlbums: '{{count}} Albums',
recentlyAdded: 'Recently Added',
decadeDistribution: 'Albums by Decade',
decadeAlbums_one: '{{count}} Album',
decadeAlbums_other: '{{count}} Albums',
decadeUnknown: 'Unknown',
},
player: {
regionLabel: 'Music Player',
openFullscreen: 'Open Fullscreen Player',
fullscreen: 'Fullscreen Player',
closeFullscreen: 'Close Fullscreen',
visualizer: 'Visualizer',
nextPreset: 'Next preset',
closeTooltip: 'Close (Esc)',
noTitle: 'No Title',
stop: 'Stop',
prev: 'Previous Track',
@@ -96,6 +427,7 @@ const deTranslation = {
mainstage: 'Mainstage',
newReleases: 'Neueste',
allAlbums: 'Alle Alben',
randomAlbums: 'Zufallsalben',
artists: 'Künstler',
playlists: 'Playlists',
randomMix: 'Zufallsmix',
@@ -103,8 +435,219 @@ const deTranslation = {
system: 'System',
statistics: 'Statistiken',
settings: 'Einstellungen',
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
},
home: {
starred: 'Persönliche Favoriten',
recent: 'Zuletzt hinzugefügt',
mostPlayed: 'Meistgehört',
discover: 'Entdecken',
loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken'
},
hero: {
eyebrow: 'Album des Augenblicks',
playAlbum: 'Album abspielen',
enqueue: 'Einreihen',
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
},
search: {
placeholder: 'Suchen nach Künstler, Album oder Song…',
noResults: 'Keine Ergebnisse für „{{query}}"',
artists: 'Künstler',
albums: 'Alben',
songs: 'Songs',
clearLabel: 'Suche leeren',
title: 'Suche',
resultsFor: 'Ergebnisse für „{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Wer hört was?',
title: 'Wer hört was?',
loading: 'Lädt…',
nobody: 'Gerade hört niemand Musik.',
minutesAgo: 'vor {{n}}m',
},
contextMenu: {
playNow: 'Direkt abspielen',
playNext: 'Als Nächstes abspielen',
addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen',
startRadio: 'Radio starten',
favorite: 'Favorisieren',
favoriteArtist: 'Künstler favorisieren',
favoriteAlbum: 'Album favorisieren',
removeFromQueue: 'Diesen Song entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
},
albumDetail: {
back: 'Zurück',
playAll: 'Alle abspielen',
enqueue: 'Einreihen',
enqueueTooltip: 'Ganzes Album zur Warteschlange hinzufügen',
artistBio: 'Künstler-Bio',
download: 'Download (ZIP)',
downloading: 'Lade…',
downloadHint: 'FLAC/WAV-Alben werden serverseitig zuerst gezippt — bei großen Alben kann es einen Moment dauern, bevor der Download startet.',
downloadHintShort: 'Server zippt zuerst — je nach Dateigröße kann es etwas dauern bevor der Download startet',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
favorite: 'Als Favorit',
noBio: 'Keine Biografie verfügbar.',
moreByArtist: 'Mehr von {{artist}}',
tracksCount: '{{n}} Tracks',
goToArtist: 'Zu {{artist}} wechseln',
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
trackTitle: 'Titel',
trackArtist: 'Interpret',
trackFormat: 'Format',
trackFavorite: 'Favorit',
trackRating: 'Bewertung',
trackDuration: 'Dauer',
trackTotal: 'Gesamt',
notFound: 'Album nicht gefunden.',
bioModal: 'Künstler-Biografie',
bioClose: 'Schließen',
ratingLabel: 'Bewertung',
},
artistDetail: {
back: 'Zurück',
albums: 'Alben',
album: 'Album',
playAll: 'Alle abspielen',
shuffle: 'Zufallswiedergabe',
radio: 'Radio',
loading: 'Lädt…',
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
notFound: 'Künstler nicht gefunden.',
albumsBy: 'Alben von {{name}}',
topTracks: 'Beliebteste Titel',
noAlbums: 'Keine Alben gefunden.',
trackTitle: 'Titel',
trackAlbum: 'Album',
trackDuration: 'Dauer',
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
favorite: 'Als Favorit',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
},
favorites: {
title: 'Favoriten',
empty: 'Du hast noch keine Favoriten gespeichert.',
artists: 'Künstler',
albums: 'Alben',
songs: 'Songs',
enqueueAll: 'Alle in die Warteschlange',
},
randomAlbums: {
title: 'Zufallsalben',
refresh: 'Neu laden',
},
randomMix: {
title: 'Zufallsmix',
remix: 'Neu mixen',
remixTooltip: 'Neue Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
trackArtist: 'Künstler',
trackAlbum: 'Album',
trackFavorite: 'Favorit',
trackDuration: 'Dauer',
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',
loading: 'Lade Playlists…',
empty: 'Keine Playlists gefunden.\nNutze die Warteschlange, um Playlists zu erstellen.',
play: 'Abspielen',
deleteTooltip: 'Löschen',
confirmDelete: 'Playlist "{{name}}" wirklich löschen?',
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',
sortByName: 'AZ (Album)',
sortByArtist: 'AZ (Künstler)',
sortNewest: 'Neueste zuerst',
sortRandom: 'Zufällig',
},
artists: {
title: 'Künstler',
search: 'Suchen…',
all: 'Alle',
gridView: 'Gitteransicht',
listView: 'Listenansicht',
loadMore: 'Mehr laden',
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
serverName: 'Server-Name (optional)',
serverNamePlaceholder: 'Mein Navidrome',
serverUrl: 'Server-URL',
serverUrlPlaceholder: '192.168.1.100:4533 oder music.example.com',
username: 'Benutzername',
usernamePlaceholder: 'admin',
password: 'Passwort',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verstecken',
connect: 'Verbinden',
connecting: 'Verbinde…',
connected: 'Verbunden!',
error: 'Verbindung fehlgeschlagen bitte Daten prüfen.',
urlRequired: 'Bitte Server-URL eingeben.',
savedServers: 'Gespeicherte Server',
addNew: 'Oder neuen Server hinzufügen',
},
common: {
albums: 'Alben',
album: 'Album',
loading: 'Lade…',
loadingMore: 'Lade…',
loadingPlaylists: 'Lade Playlists…',
noAlbums: 'Keine Alben gefunden.',
downloading: 'Lade…',
downloadZip: 'Download (ZIP)',
back: 'Zurück',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
use: 'Verwenden',
add: 'Hinzufügen',
active: 'Aktiv',
},
settings: {
title: 'Einstellungen',
@@ -113,19 +656,29 @@ const deTranslation = {
languageDe: 'Deutsch',
theme: 'Design',
appearance: 'Darstellung',
connection: 'Verbindung',
lanIp: 'LAN-IP',
externalUrl: 'Externe URL',
servers: 'Server',
serverName: 'Server-Name',
serverUrl: 'Server-URL',
serverUsername: 'Benutzername',
serverPassword: 'Passwort',
addServer: 'Server hinzufügen',
addServerTitle: 'Neuen Server hinzufügen',
useServer: 'Verwenden',
deleteServer: 'Löschen',
noServers: 'Keine Server gespeichert.',
serverActive: 'Aktiv',
confirmDeleteServer: 'Server „{{name}}" löschen?',
serverConnecting: 'Verbinde…',
serverConnected: 'Verbunden!',
serverFailed: 'Verbindung fehlgeschlagen.',
testBtn: 'Verbindung testen',
testingBtn: 'Teste…',
connected: 'Verbunden',
failed: 'Fehlgeschlagen',
activeConn: 'Aktive Verbindung',
activeServer: 'Aktuell verwendeter Server:',
connLocal: 'Lokal (LAN)',
connExternal: 'Extern (Internet)',
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 <strong>Navidrome Webplayer</strong> im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
lfmDesc1: 'Psysonic unterstützt serverseitiges Scrobbling direkt über Navidrome. Um Last.fm zu verknüpfen, logge dich bitte einmalig über den',
lfmDesc1NavidromeWebplayer: 'Navidrome Webplayer',
lfmDesc1b: 'im Browser ein, gehe auf dein Profil und verbinde deinen Last.fm Account.',
lfmDesc2: 'Sobald das erledigt ist, leitet Psysonic deine aktuell gespielten Songs automatisch an Navidrome weiter, und diese erscheinen auf Last.fm.',
scrobbleEnabled: 'Scrobbling aktiviert',
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
@@ -138,7 +691,89 @@ const deTranslation = {
downloadsDefault: 'Standard-Downloads-Ordner',
pickFolder: 'Auswählen',
pickFolderTitle: 'Download-Ordner auswählen',
logout: 'Abmelden'
logout: 'Abmelden',
aboutTitle: 'Über Psysonic',
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutLicense: 'Lizenz',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
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)',
},
help: {
title: 'Hilfe',
s1: 'Erste Schritte',
q1: 'Welche Server sind kompatibel?',
a1: 'Psysonic funktioniert mit jedem Subsonic-kompatiblen Server: Navidrome, Gonic, Subsonic, Airsonic und anderen. Navidrome ist die empfohlene Wahl.',
q2: 'Wie verbinde ich mich mit meinem Server?',
a2: 'Öffne die Einstellungen und klicke auf "Server hinzufügen". Gib die Server-URL (z. B. 192.168.1.100:4533), Benutzername und Passwort ein. Psysonic testet die Verbindung vor dem Speichern — bei Fehler wird nichts gespeichert.',
q3: 'Kann ich mehrere Server verwenden?',
a3: 'Ja. Du kannst beliebig viele Server in den Einstellungen hinzufügen und jederzeit zwischen ihnen wechseln. Immer ist nur ein Server aktiv.',
s2: 'Wiedergabe',
q4: 'Wie spiele ich Musik ab?',
a4: 'Doppelklick auf einen Track startet die Wiedergabe. Auf Album- und Künstlerseiten gibt es "Alle abspielen". Tracks lassen sich auch per Drag & Drop in die Warteschlange ziehen.',
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.',
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?',
a8: 'Klick auf den Wiederhol-Button in der Playerleiste, um zwischen den Modi zu wechseln: Aus → Alles wiederholen → Einen wiederholen.',
s3: 'Bibliothek',
q9: 'Wie lade ich ein Album herunter?',
a9: 'Auf der Album-Detailseite auf "Download (ZIP)" klicken. Der Server zieht das Album zuerst zusammen — bei großen Alben oder verlustfreien Dateien (FLAC / WAV) kann das einen Moment dauern. Ein Fortschrittsbalken zeigt den Status.',
q10: 'Wie markiere ich Tracks und Alben als Favoriten?',
a10: 'Das Stern-Icon auf einem Track oder im Album-Header anklicken. Markierte Einträge erscheinen im Bereich "Favoriten" in der Seitenleiste.',
q11: 'Was ist das Karussell auf der Startseite?',
a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.',
s4: 'Einstellungen',
q12: 'Wie ändere ich das Theme?',
a12: 'Einstellungen → Theme. Wahl zwischen Catppuccin Mocha (dunkel) und Catppuccin Latte (hell).',
q13: 'Wie ändere ich die Sprache?',
a13: 'Einstellungen → Sprache. Aktuell verfügbar: Englisch und Deutsch.',
q14: 'Was bewirkt "In Tray minimieren"?',
a14: 'Wenn aktiviert, versteckt sich Psysonic beim Klick auf × im System-Tray statt sich zu schließen. Die Musik läuft weiter. Rechtsklick auf das Tray-Icon zeigt Steueroptionen und "Beenden".',
q15: 'Wie lege ich einen Download-Ordner fest?',
a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.',
s5: 'Scrobbling',
q16: 'Wie funktioniert Scrobbling?',
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.',
q19: 'Der Verbindungstest schlägt fehl.',
a19: 'URL inklusive Port prüfen (z. B. http://192.168.1.100:4533). Sicherstellen, dass keine Firewall die Verbindung blockiert. Im lokalen Netzwerk http:// statt https:// versuchen. Benutzername und Passwort kontrollieren.',
q20: 'Kein Ton unter Linux (AppImage).',
a20: 'Das AppImage bündelt GStreamer. Falls trotzdem kein Ton kommt, diese System-Pakete installieren: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.',
q21: 'Die App stürzt ab oder zeigt einen schwarzen Bildschirm auf alter Linux-Hardware.',
a21: 'Das liegt meist an GPU/EGL-Treiberproblemen in WebKitGTK. Das offizielle AppImage deaktiviert GPU-Compositing bereits automatisch. Beim Selbst-Kompilieren: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic',
},
queue: {
title: 'Warteschlange',
@@ -147,20 +782,47 @@ const deTranslation = {
cancel: 'Abbrechen',
save: 'Speichern',
loadPlaylist: 'Playlist laden',
loading: 'Lade...',
loading: 'Lade',
noPlaylists: 'Keine Playlists gefunden.',
load: 'Laden',
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.'
emptyQueue: 'Die Warteschlange ist leer.',
trackSingular: 'Titel',
trackPlural: 'Titel',
},
statistics: {
title: 'Statistiken',
recentlyPlayed: 'Zuletzt gehört',
mostPlayed: 'Meistgespielte Alben',
highestRated: 'Höchstbewertete Alben',
genreDistribution: 'Genre-Verteilung (Top 20)',
loadMore: 'Mehr laden',
statArtists: 'Künstler',
statAlbums: 'Alben',
statSongs: 'Songs',
statGenres: 'Genres',
genreSongs: '{{count}} Songs',
genreAlbums: '{{count}} Alben',
recentlyAdded: 'Neu hinzugefügt',
decadeDistribution: 'Alben nach Jahrzehnt',
decadeAlbums_one: '{{count}} Album',
decadeAlbums_other: '{{count}} Alben',
decadeUnknown: 'Unbekannt',
},
player: {
regionLabel: 'Musikplayer',
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',
prev: 'Vorheriger Titel',
@@ -190,11 +852,10 @@ i18n
lng: savedLanguage,
fallbackLng: 'en',
interpolation: {
escapeValue: false // react already safes from xss
escapeValue: false
}
});
// Setup listener to persist language changes
i18n.on('languageChanged', (lng) => {
localStorage.setItem('psysonic_language', lng);
});
+114 -297
View File
@@ -1,103 +1,59 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus } from 'lucide-react';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { useParams, useNavigate } from 'react-router-dom';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList';
import { useCachedUrl } from '../components/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 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(' · ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const [hover, setHover] = useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label="Bewertung">
{[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} Stern${n !== 1 ? 'e' : ''}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
interface BioModalProps { bio: string; onClose: () => void; }
function BioModal({ bio, onClose }: BioModalProps) {
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="Künstler-Biografie">
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Schließen"><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>Künstler-Biografie</h3>
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: bio }} data-selectable />
</div>
</div>
);
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
export default function AlbumDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const auth = useAuthStore();
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>>({});
const [bio, setBio] = useState<string | null>(null);
const [bioOpen, setBioOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
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;
setLoading(true);
setRelatedAlbums([]);
getAlbum(id).then(async data => {
setAlbum(data);
getAlbum(id).then(async data => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
// Fetch related albums by the same artist
setLoading(false);
try {
const artistData = await getArtist(data.album.artistId);
// Filter out the current album from the related list
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
} catch (e) {
console.error('Failed to fetch related albums', e);
@@ -109,8 +65,8 @@ export default function AlbumDetail() {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
@@ -119,18 +75,17 @@ 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, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
};
const handlePlaySong = (song: SubsonicSong) => {
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
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,
};
playTrack(track, [track]);
};
@@ -144,278 +99,140 @@ export default function AlbumDetail() {
if (!album) return;
if (bio) { setBioOpen(true); return; }
const info = await getArtistInfo(album.album.artistId);
setBio(info.biography ?? 'Keine Biografie verfügbar.');
setBio(info.biography ?? t('albumDetail.noBio'));
setBioOpen(true);
};
const handleDownload = async (albumName: string, albumId: string) => {
setDownloading(true);
const handleDownload = async () => {
if (!album) return;
const { name, id: albumId } = album.album;
setDownloadProgress(0);
try {
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
if (total && response.body) {
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
setDownloadProgress(Math.round((received / total) * 100));
}
} else {
const buffer = await response.arrayBuffer() as ArrayBuffer;
chunks.push(new Uint8Array(buffer));
setDownloadProgress(100);
}
const blob = new Blob(chunks);
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
const path = await join(auth.downloadFolder, `${albumName}.zip`);
const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
console.log(`Saved to ${path}`);
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `${albumName}.zip`;
a.download = `${sanitizeFilename(name)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
} catch (e) {
console.error('Download fehlgeschlagen:', e);
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
setDownloading(false);
setTimeout(() => setDownloadProgress(null), 60000);
}
};
const toggleStar = async () => {
if (!album) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred); // Optimistic UI update
const wasStarred = isStarred;
setIsStarred(!wasStarred);
try {
if (currentlyStarred) {
await unstar(album.album.id);
} else {
await star(album.album.id);
}
if (wasStarred) await unstar(album.album.id);
else await star(album.album.id);
} catch (e) {
console.error('Failed to toggle star', e);
setIsStarred(currentlyStarred); // Revert on failure
setIsStarred(wasStarred);
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation(); // prevent play on double click trigger
const currentlyStarred = starredSongs.has(song.id);
// Optimistic UI
const nextStarred = new Set(starredSongs);
if (currentlyStarred) nextStarred.delete(song.id);
else nextStarred.add(song.id);
setStarredSongs(nextStarred);
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);
try {
if (currentlyStarred) {
await unstar(song.id, 'song');
} else {
await star(song.id, 'song');
}
if (wasStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
// Revert
const revert = new Set(starredSongs);
setStarredSongs(revert);
setStarredSongs(new Set(starredSongs));
}
};
// Hooks must be called unconditionally — derive from nullable album state
const coverUrl = album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '';
const coverKey = album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '';
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">Album nicht gefunden.</div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
const { album: info, songs } = album;
const coverUrl = info.coverArt ? buildCoverArtUrl(info.coverArt, 400) : '';
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
return (
<div className="album-detail animate-fade-in">
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
<AlbumHeader
info={info}
songs={songs}
coverUrl={coverUrl}
coverKey={coverKey}
resolvedCoverUrl={resolvedCoverUrl}
isStarred={isStarred}
downloadProgress={downloadProgress}
bio={bio}
bioOpen={bioOpen}
onToggleStar={toggleStar}
onDownload={handleDownload}
onPlayAll={handlePlayAll}
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
/>
<div className="album-detail-header">
{coverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${coverUrl})` }}
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} /> Zurück
</button>
<div className="album-detail-hero">
{coverUrl ? (
<img className="album-detail-cover" src={coverUrl} 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' }}>Album</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
className="album-detail-artist-link"
data-tooltip={`Zu ${info.artist} wechseln`}
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={`Weitere Alben von ${info.recordLabel} anzeigen`}
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" /> Alle abspielen
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
data-tooltip="Ganzes Album zur Warteschlange hinzufügen"
>
<ListPlus size={16} /> Einreihen
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={toggleStar}
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
{isStarred ? 'Favorit' : 'Als Favorit'}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
<ExternalLink size={16} /> Künstler-Bio
</button>
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)} disabled={downloading}>
<Download size={16} /> {downloading ? 'Lade…' : 'Download (ZIP)'}
</button>
</div>
</div>
</div>
</div>
</div>
<div className="tracklist">
<div className="tracklist-header">
<div style={{ textAlign: 'center' }}>#</div>
<div>Titel</div>
<div>Format</div>
<div style={{ textAlign: 'center' }}>Favorit</div>
<div>Bewertung</div>
<div style={{ textAlign: 'right' }}>Dauer</div>
</div>
{(() => {
// Group songs by disc number
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"
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('application/json', 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>
{song.artist !== info.artist && (
<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) ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
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>
));
})()}
</div>
<AlbumTrackList
songs={songs}
hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack}
isPlaying={isPlaying}
hoveredSongId={hoveredSongId}
setHoveredSongId={setHoveredSongId}
ratings={ratings}
starredSongs={starredSongs}
onPlaySong={handlePlaySong}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu}
/>
{relatedAlbums.length > 0 && (
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
<h2 className="section-title" style={{ marginBottom: '1rem' }}>Mehr von {info.artist}</h2>
<div className="album-related">
<div className="album-related-divider" />
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<div className="album-grid-wrap">
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
+7 -7
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist' | 'newest' | 'random';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
export default function Albums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [sort, setSort] = useState<SortType>('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -51,16 +53,14 @@ export default function Albums() {
}, [loadMore]);
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: 'AZ (Album)' },
{ value: 'alphabeticalByArtist', label: 'AZ (Künstler)' },
{ value: 'newest', label: 'Neueste zuerst' },
{ value: 'random', label: 'Zufällig' },
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 className="page-title">Alle Alben</h1>
<h1 className="page-title">{t('albums.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
<button
@@ -84,7 +84,7 @@ export default function Albums() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
+73 -55
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, 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';
import { open } from '@tauri-apps/plugin-shell';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -12,7 +14,23 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Inline Last.fm SVG icon
/** Strip dangerous tags/attributes from server-provided HTML */
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 LastfmIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -22,6 +40,7 @@ function LastfmIcon({ size = 16 }: { size?: number }) {
}
export default function ArtistDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
@@ -31,7 +50,7 @@ export default function ArtistDetail() {
const [loading, setLoading] = useState(true);
const [radioLoading, setRadioLoading] = useState(false);
const [isStarred, setIsStarred] = useState(false);
const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue);
const clearQueue = usePlayerStore(state => state.clearQueue);
@@ -44,7 +63,6 @@ export default function ArtistDetail() {
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => [])
@@ -59,21 +77,27 @@ export default function ArtistDetail() {
});
}, [id]);
const openLink = (url: string) => open(url);
const openLink = (url: string, title: string) => {
const label = `browser_${Date.now()}`;
new WebviewWindow(label, {
url,
title,
width: 1100,
height: 780,
center: true,
});
};
const toggleStar = async () => {
if (!artist) return;
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred); // Optimistic UI update
setIsStarred(!currentlyStarred);
try {
if (currentlyStarred) {
await unstar(artist.id, 'artist');
} else {
await star(artist.id, 'artist');
}
if (currentlyStarred) await unstar(artist.id, 'artist');
else await star(artist.id, 'artist');
} catch (e) {
console.error('Failed to toggle star', e);
setIsStarred(currentlyStarred); // Revert on failure
setIsStarred(currentlyStarred);
}
};
@@ -101,10 +125,10 @@ export default function ArtistDetail() {
clearQueue();
playTrack(similar[0], similar);
} else {
alert("Keine ähnlichen Titel für diesen Künstler gefunden.");
alert(t('artistDetail.noRadio'));
}
} catch (e) {
console.error("Radio start failed", e);
console.error('Radio start failed', e);
} finally {
setRadioLoading(false);
}
@@ -122,7 +146,7 @@ export default function ArtistDetail() {
return (
<div className="content-body">
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
Künstler nicht gefunden.
{t('artistDetail.notFound')}
</div>
</div>
);
@@ -138,18 +162,18 @@ export default function ArtistDetail() {
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} /> <span>Zurück</span>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
{/* Header: avatar + name + meta + links */}
<div className="artist-detail-header">
<div className="artist-detail-avatar">
{coverId ? (
<img
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<Users size={64} color="var(--text-muted)" />
@@ -161,63 +185,61 @@ export default function ArtistDetail() {
{artist.name}
</h1>
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{/* External links */}
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
{info?.lastFmUrl && (
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!)}>
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, `${artist.name} — Last.fm`)}>
<LastfmIcon size={14} />
Last.fm
</button>
)}
<button className="artist-ext-link" onClick={() => openLink(wikiUrl)}>
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, `${artist.name} — Wikipedia`)}>
<ExternalLink size={14} />
Wikipedia
</button>
</div>
)}
{/* Star toggle */}
<button
className="artist-ext-link"
<button
className="artist-ext-link"
onClick={toggleStar}
data-tooltip={isStarred ? "Aus Favoriten entfernen" : "Zu Favoriten hinzufügen"}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
{isStarred ? 'Favorit' : 'Als Favorit'}
<Star size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')}
</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
{topSongs.length > 0 && (
<>
<button className="btn btn-primary" onClick={handlePlayAll}>
<Play size={16} /> Alle abspielen
<Play size={16} /> {t('artistDetail.playAll')}
</button>
<button className="btn btn-surface" onClick={handleShuffle}>
<Shuffle size={16} /> Zufallswiedergabe
<Shuffle size={16} /> {t('artistDetail.shuffle')}
</button>
</>
)}
<button className="btn btn-surface" onClick={handleStartRadio} disabled={radioLoading}>
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{radioLoading ? 'Lädt...' : 'Radio'}
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
</button>
</div>
</div>
</div>
{/* Biography — only shown when available */}
{/* Biography — sanitized HTML from server */}
{info?.biography && (
<div className="artist-bio-section">
<div
className="artist-bio-text"
dangerouslySetInnerHTML={{ __html: info.biography }}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(info.biography) }}
/>
</div>
)}
@@ -226,14 +248,14 @@ export default function ArtistDetail() {
{topSongs.length > 0 && (
<>
<h2 className="section-title" style={{ marginTop: info?.biography ? '2rem' : '0', marginBottom: '1rem' }}>
Beliebteste Titel
{t('artistDetail.topTracks')}
</h2>
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
<div style={{ textAlign: 'center' }}>#</div>
<div>Titel</div>
<div>Album</div>
<div style={{ textAlign: 'right' }}>Dauer</div>
<div>{t('artistDetail.trackTitle')}</div>
<div>{t('artistDetail.trackAlbum')}</div>
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
</div>
{topSongs.map((song, idx) => (
<div
@@ -245,34 +267,30 @@ export default function ArtistDetail() {
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,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>
{idx + 1}
</div>
<div className="track-num" style={{ textAlign: 'center' }}>{idx + 1}</div>
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{song.coverArt && (
<img
src={buildCoverArtUrl(song.coverArt, 64)}
alt={song.album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
<CachedImage
src={buildCoverArtUrl(song.coverArt, 64)}
cacheKey={coverArtCacheKey(song.coverArt, 64)}
alt={song.album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
</div>
</div>
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
{song.album}
</div>
<div className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</div>
@@ -284,7 +302,7 @@ export default function ArtistDetail() {
{/* Albums */}
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0) ? '2rem' : '0', marginBottom: '1rem' }}>
Alben von {artist.name}
{t('artistDetail.albumsBy', { name: artist.name })}
</h2>
{albums.length > 0 ? (
@@ -292,7 +310,7 @@ export default function ArtistDetail() {
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
) : (
<p style={{ color: 'var(--text-muted)' }}>Keine Alben gefunden.</p>
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
)}
</div>
);
+54 -54
View File
@@ -1,18 +1,44 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
import { Users, LayoutGrid, List } from 'lucide-react';
import { getArtists, SubsonicArtist } from '../api/subsonic';
import { LayoutGrid, List } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
const ALPHABET = ['Alle', '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
// Catppuccin accent colors — one is picked deterministically from the artist name
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
'var(--ctp-blue)', 'var(--ctp-lavender)',
];
function nameColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return CTP_COLORS[h % CTP_COLORS.length];
}
function nameInitial(name: string): string {
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
if (letter) return letter.toUpperCase();
// Fallback: first alphanumeric (e.g. "1349")
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
return alnum?.toUpperCase() ?? '?';
}
export default function Artists() {
const { t } = useTranslation();
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [letterFilter, setLetterFilter] = useState('Alle');
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [visibleCount, setVisibleCount] = useState(50);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -32,8 +58,8 @@ export default function Artists() {
// Filter pipeline
let filtered = artists;
if (letterFilter !== 'Alle') {
if (letterFilter !== ALL_SENTINEL) {
filtered = filtered.filter(a => {
const first = a.name[0]?.toUpperCase() ?? '#';
const isAlpha = /^[A-Z]$/.test(first);
@@ -63,31 +89,31 @@ export default function Artists() {
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>Künstler</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
<input
className="input"
style={{ maxWidth: 220 }}
placeholder="Suchen…"
placeholder={t('artists.search')}
value={filter}
onChange={e => setFilter(e.target.value)}
id="artist-filter-input"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip="Grid ansicht"
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip="Listenansicht"
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
@@ -111,7 +137,7 @@ export default function Artists() {
transition: 'all 0.2s'
}}
>
{l}
{l === ALL_SENTINEL ? t('artists.all') : l}
</button>
))}
</div>
@@ -121,10 +147,10 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<div className="album-grid-wrap">
{visible.map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<div
key={artist.id}
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
@@ -132,26 +158,13 @@ export default function Artists() {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 200)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={32} />
)}
<Users size={32} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{artist.albumCount} Alben</div>
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</div>
@@ -167,7 +180,7 @@ export default function Artists() {
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => {
const coverId = artist.coverArt || artist.id;
const color = nameColor(artist.name);
return (
<button
key={artist.id}
@@ -179,26 +192,13 @@ export default function Artists() {
}}
id={`artist-${artist.id}`}
>
<div className="artist-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 100)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
}}
/>
) : (
<Users size={18} />
)}
<Users size={18} className="fallback-icon" style={{ display: coverId ? 'none' : 'block', position: 'absolute' }} />
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{artist.albumCount} Alben</div>
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
@@ -213,14 +213,14 @@ export default function Artists() {
{!loading && hasMore && (
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-ghost" onClick={loadMore}>
Mehr laden
{t('artists.loadMore')}
</button>
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
Keine Künstler gefunden.
{t('artists.notFound')}
</div>
)}
</div>
+69 -44
View File
@@ -3,15 +3,20 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play } from 'lucide-react';
import { Play, ListPlus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default function Favorites() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true);
const { playTrack } = usePlayerStore();
const { playTrack, enqueue } = usePlayerStore();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
useEffect(() => {
getStarred()
@@ -37,65 +42,85 @@ export default function Favorites() {
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div style={{ marginBottom: '-1.5rem' }}>
<h1 className="page-title">Favoriten</h1>
<h1 className="page-title">{t('favorites.title')}</h1>
</div>
{!hasAnyFavorites ? (
<div className="empty-state">Du hast noch keine Favoriten gespeichert.</div>
<div className="empty-state">{t('favorites.empty')}</div>
) : (
<>
{artists.length > 0 && (
<ArtistRow title="Künstler" artists={artists} />
<ArtistRow title={t('favorites.artists')} artists={artists} />
)}
{albums.length > 0 && (
<AlbumRow title="Alben" albums={albums} />
<AlbumRow title={t('favorites.albums')} albums={albums} />
)}
{songs.length > 0 && (
<section className="album-row-section">
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}>Songs</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-surface"
onClick={() => {
const tracks = songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
{/* Wir können für die Favoriten-Seite ruhig alle Songs anzeigen, statt nur 10 wie auf der Startseite */}
{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('application/json', JSON.stringify({
type: 'song',
track
}));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
<div className="tracklist-header tracklist-va">
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
</div>
{songs.map((song, i) => {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
return (
<div
key={song.id}
className="track-row track-row-va"
onDoubleClick={() => playTrack(song, songs)}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" title={song.title}>{song.title}</span>
<span className="track-artist">{song.artist}</span>
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
{i + 1}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>{song.artist}</span>
</div>
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</span>
</div>
))}
);
})}
</div>
</section>
)}
+121
View File
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { ChevronDown, Rocket, Play, LibraryBig, Settings2, Radio, Wrench, Shuffle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface FaqItem { q: string; a: string; }
interface FaqSection { icon: React.ReactNode; title: string; items: FaqItem[]; }
function AccordionItem({ q, a, open, onToggle }: FaqItem & { open: boolean; onToggle: () => void }) {
return (
<div className={`help-item${open ? ' help-item-open' : ''}`}>
<button className="help-question" onClick={onToggle} aria-expanded={open}>
<span>{q}</span>
<ChevronDown size={16} className="help-chevron" />
</button>
{open && <div className="help-answer">{a}</div>}
</div>
);
}
export default function Help() {
const { t } = useTranslation();
const [openKey, setOpenKey] = useState<string | null>(null);
const toggle = (key: string) => setOpenKey(prev => prev === key ? null : key);
const sections: FaqSection[] = [
{
icon: <Rocket size={18} />,
title: t('help.s1'),
items: [
{ q: t('help.q1'), a: t('help.a1') },
{ q: t('help.q2'), a: t('help.a2') },
{ q: t('help.q3'), a: t('help.a3') },
],
},
{
icon: <Play size={18} />,
title: t('help.s2'),
items: [
{ q: t('help.q4'), a: t('help.a4') },
{ q: t('help.q5'), a: t('help.a5') },
{ 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') },
],
},
{
icon: <LibraryBig size={18} />,
title: t('help.s3'),
items: [
{ 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') },
],
},
{
icon: <Settings2 size={18} />,
title: t('help.s4'),
items: [
{ q: t('help.q12'), a: t('help.a12') },
{ q: t('help.q13'), a: t('help.a13') },
{ q: t('help.q14'), a: t('help.a14') },
{ q: t('help.q15'), a: t('help.a15') },
],
},
{
icon: <Radio size={18} />,
title: t('help.s5'),
items: [
{ q: t('help.q16'), a: t('help.a16') },
{ 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'),
items: [
{ q: t('help.q18'), a: t('help.a18') },
{ q: t('help.q19'), a: t('help.a19') },
{ q: t('help.q20'), a: t('help.a20') },
{ q: t('help.q21'), a: t('help.a21') },
],
},
];
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<div className="settings-section-header">
{section.icon}
<h2>{section.title}</h2>
</div>
<div className="help-list">
{section.items.map((item, ii) => {
const key = `${si}-${ii}`;
return <AccordionItem key={key} q={item.q} a={item.a} open={openKey === key} onToggle={() => toggle(key)} />;
})}
</div>
</section>
))}
</div>
</div>
);
}
+16 -11
View File
@@ -2,11 +2,13 @@ import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -14,12 +16,13 @@ export default function Home() {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
getAlbumList('frequent', 12).catch(() => []),
]).then(([s, n, r, f]) => {
setStarred(s);
setRecent(n);
setRandom(r);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
setLoading(false);
}).catch(() => setLoading(false));
@@ -42,9 +45,11 @@ export default function Home() {
}
};
const { t } = useTranslation();
return (
<div className="animate-fade-in">
<Hero />
<Hero albums={heroAlbums} />
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{loading ? (
@@ -55,29 +60,29 @@ export default function Home() {
<>
{starred.length > 0 && (
<AlbumRow
title="Persönliche Favoriten"
title={t('home.starred')}
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
)}
<AlbumRow
title="Zuletzt hinzugefügt"
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
<AlbumRow
title="Meistgehört"
title={t('home.mostPlayed')}
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText="Mehr laden"
moreText={t('home.loadMore')}
/>
<AlbumRow
title="Entdecken"
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText="Mehr entdecken"
moreText={t('home.discoverMore')}
/>
</>
)}
+9 -7
View File
@@ -3,8 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { search, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function LabelAlbums() {
const { t } = useTranslation();
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -13,17 +15,17 @@ export default function LabelAlbums() {
useEffect(() => {
if (!name) return;
setLoading(true);
// Search for the label name and ask for a large number of albums
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
.then(res => {
// Filter out albums that don't match the record label exactly if possible,
// Filter out albums that don't match the record label exactly if possible,
// to avoid unrelated search hits. We do case-insensitive comparison.
const matches = res.albums.filter(a =>
const matches = res.albums.filter(a =>
a.recordLabel?.toLowerCase() === name.toLowerCase()
);
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
// (or it's not indexed exactly as typed), just show all album matches
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
// (or it's not indexed exactly as typed), just show all album matches
// as a decent best-effort if our strict filter yields nothing.
setAlbums(matches.length > 0 ? matches : res.albums);
})
@@ -34,7 +36,7 @@ export default function LabelAlbums() {
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ margin: '1rem 0', gap: '6px' }}>
<ChevronLeft size={16} /> Zurück
<ChevronLeft size={16} /> {t('common.back')}
</button>
<h1 className="page-title" style={{ marginBottom: '2rem' }}>
@@ -46,7 +48,7 @@ export default function LabelAlbums() {
<div className="spinner" />
</div>
) : albums.length === 0 ? (
<div className="empty-state">Keine Alben für dieses Label gefunden.</div>
<div className="empty-state">{t('common.noAlbums')}</div>
) : (
<div className="album-grid-wrap">
{albums.map(a => (
+92 -71
View File
@@ -1,38 +1,20 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { ping } from '../api/subsonic';
import { pingWithCredentials } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PsysonicLogo = () => (
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="18" fill="url(#grad-login)" />
<text x="8" y="47" fontFamily="Inter, sans-serif" fontWeight="800" fontSize="42" fill="white">P</text>
<line x1="40" y1="18" x2="58" y2="18" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
<line x1="37" y1="26" x2="58" y2="26" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.75"/>
<line x1="40" y1="34" x2="58" y2="34" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.9"/>
<line x1="37" y1="42" x2="58" y2="42" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.6"/>
<line x1="42" y1="50" x2="58" y2="50" stroke="white" strokeWidth="3.5" strokeLinecap="round" opacity="0.4"/>
<defs>
<linearGradient id="grad-login" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop stopColor="#cba6f7"/>
<stop offset="1" stopColor="#89b4fa"/>
</linearGradient>
</defs>
</svg>
<img src="/logo.png" width="64" height="64" alt="Psysonic" style={{ borderRadius: 18 }} />
);
export default function Login() {
const navigate = useNavigate();
const { setCredentials, setLoggedIn, setConnecting, setConnectionError, connectionError } = useAuthStore();
const { t } = useTranslation();
const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore();
const [form, setForm] = useState({
serverName: '',
lanIp: '',
externalUrl: '',
username: '',
password: '',
});
const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' });
const [showPass, setShowPass] = useState(false);
const [status, setStatus] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
@@ -40,38 +22,69 @@ export default function Login() {
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.lanIp && !form.externalUrl) {
setTestMessage('Bitte LAN-IP oder externe URL eingeben.');
const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => {
if (!profile.url.trim()) {
setTestMessage(t('login.urlRequired'));
setStatus('error');
return;
}
setStatus('testing');
setTestMessage('Verbinde…');
setTestMessage(t('login.connecting'));
setConnecting(true);
setCredentials(form);
setConnectionError(null);
// Small delay to let store update
await new Promise(r => setTimeout(r, 100));
// Test connection directly with entered credentials — don't touch the store yet.
// This avoids any race condition with Zustand's async store rehydration.
let ok = false;
try {
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
} catch {
ok = false;
}
const ok = await ping();
setConnecting(false);
if (ok) {
// Connection succeeded — now persist to store
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
let serverId: string;
if (existing) {
updateServer(existing.id, {
name: profile.name.trim() || profile.url.trim(),
password: profile.password,
});
serverId = existing.id;
} else {
serverId = addServer({
name: profile.name.trim() || profile.url.trim(),
url: profile.url.trim(),
username: profile.username.trim(),
password: profile.password,
});
}
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
setTestMessage('Verbunden!');
setTestMessage(t('login.connected'));
setTimeout(() => navigate('/'), 600);
} else {
setStatus('error');
setConnectionError('Verbindung fehlgeschlagen bitte Daten prüfen.');
setTestMessage('Verbindung fehlgeschlagen.');
setConnectionError(t('login.error'));
setTestMessage(t('login.error'));
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password });
};
const handleQuickConnect = async (srv: typeof servers[0]) => {
setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password });
await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password });
};
return (
<div className="login-page">
<div className="login-bg" aria-hidden="true" />
@@ -80,64 +93,72 @@ export default function Login() {
<PsysonicLogo />
</div>
<h1 className="login-title">Psysonic</h1>
<p className="login-subtitle">Dein Navidrome Desktop Player</p>
<p className="login-subtitle">{t('login.subtitle')}</p>
<form className="login-form" onSubmit={handleConnect} noValidate>
{/* Saved servers quick-connect */}
{servers.length > 0 && (
<div className="login-saved-servers">
<div className="login-saved-label">{t('login.savedServers')}</div>
{servers.map(srv => (
<button
key={srv.id}
className="btn btn-surface login-server-btn"
onClick={() => handleQuickConnect(srv)}
disabled={status === 'testing'}
>
<Server size={14} style={{ flexShrink: 0 }} />
<div style={{ textAlign: 'left', minWidth: 0 }}>
<div style={{ fontWeight: 600 }} className="truncate">{srv.name || srv.url}</div>
<div style={{ fontSize: 11, opacity: 0.7 }} className="truncate">{srv.username}@{srv.url}</div>
</div>
</button>
))}
<div className="login-divider"><span>{t('login.addNew')}</span></div>
</div>
)}
<form className="login-form" onSubmit={handleFormSubmit} noValidate>
<div className="form-group">
<label htmlFor="login-server-name">Server-Name (optional)</label>
<label htmlFor="login-server-name">{t('login.serverName')}</label>
<input
id="login-server-name"
className="input"
type="text"
placeholder="Mein Navidrome"
placeholder={t('login.serverNamePlaceholder')}
value={form.serverName}
onChange={update('serverName')}
autoComplete="off"
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="login-lan-ip">LAN-IP / URL</label>
<input
id="login-lan-ip"
className="input"
type="text"
placeholder="192.168.1.100:4533"
value={form.lanIp}
onChange={update('lanIp')}
autoComplete="off"
/>
</div>
<div className="form-group">
<label htmlFor="login-external-url">Externe URL (FQDN)</label>
<input
id="login-external-url"
className="input"
type="text"
placeholder="music.example.com"
value={form.externalUrl}
onChange={update('externalUrl')}
autoComplete="off"
/>
</div>
<div className="form-group">
<label htmlFor="login-url">{t('login.serverUrl')}</label>
<input
id="login-url"
className="input"
type="text"
placeholder={t('login.serverUrlPlaceholder')}
value={form.url}
onChange={update('url')}
autoComplete="off"
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="login-username">Benutzername</label>
<label htmlFor="login-username">{t('login.username')}</label>
<input
id="login-username"
className="input"
type="text"
placeholder="admin"
placeholder={t('login.usernamePlaceholder')}
value={form.username}
onChange={update('username')}
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="login-password">Passwort</label>
<label htmlFor="login-password">{t('login.password')}</label>
<div style={{ position: 'relative' }}>
<input
id="login-password"
@@ -153,7 +174,7 @@ export default function Login() {
type="button"
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
onClick={() => setShowPass(v => !v)}
aria-label={showPass ? 'Passwort verstecken' : 'Passwort anzeigen'}
aria-label={showPass ? t('login.hidePassword') : t('login.showPassword')}
>
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
@@ -177,7 +198,7 @@ export default function Login() {
id="login-connect-btn"
disabled={status === 'testing'}
>
{status === 'testing' ? 'Verbinde…' : 'Verbinden'}
{status === 'testing' ? t('login.connecting') : t('login.connect')}
</button>
</form>
</div>
+3 -1
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function NewReleases() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -49,7 +51,7 @@ export default function NewReleases() {
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
+99 -79
View File
@@ -1,117 +1,137 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { ListMusic, Play, Trash2 } from 'lucide-react';
import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type SortKey = 'name' | 'songCount' | 'duration';
type SortDir = 'asc' | 'desc';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function SortHeader({
label, sortKey, current, dir, onSort
}: {
label: string;
sortKey: SortKey;
current: SortKey;
dir: SortDir;
onSort: (k: SortKey) => void;
}) {
const active = current === sortKey;
return (
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
{label}
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
);
}
export default function Playlists() {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue);
const fetchPlaylists = () => {
setLoading(true);
getPlaylists()
.then(data => {
setPlaylists(data);
setLoading(false);
})
.catch(err => {
console.error('Failed to load playlists', err);
setLoading(false);
});
.then(data => { setPlaylists(data); setLoading(false); })
.catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
};
useEffect(() => {
fetchPlaylists();
}, []);
useEffect(() => { fetchPlaylists(); }, []);
const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const handlePlay = async (id: string) => {
try {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) {
clearQueue();
playTrack(tracks[0], tracks);
}
} catch (e) {
console.error('Failed to play playlist', e);
}
if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
} catch (e) { console.error('Failed to play playlist', e); }
};
const handleDelete = async (id: string, name: string) => {
if (confirm(`Playlist "${name}" wirklich löschen?`)) {
try {
await deletePlaylist(id);
fetchPlaylists();
} catch (e) {
console.error('Failed to delete playlist', e);
}
if (confirm(t('playlists.confirmDelete', { name }))) {
try { await deletePlaylist(id); fetchPlaylists(); }
catch (e) { console.error('Failed to delete playlist', e); }
}
};
const visible = useMemo(() => {
const q = filter.toLowerCase();
const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
return [...filtered].sort((a, b) => {
let cmp = 0;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
else cmp = a.duration - b.duration;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [playlists, filter, sortKey, sortDir]);
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
<h1 className="page-title" style={{ margin: 0 }}>Playlists</h1>
<div className="playlist-page-header">
<h1 className="page-title">{t('playlists.title')}</h1>
<input
className="playlist-filter-input"
type="search"
placeholder={t('playlists.filterPlaceholder')}
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</div>
{loading ? (
<div className="empty-state">Lade Playlists...</div>
<div className="loading-center"><div className="spinner" /></div>
) : playlists.length === 0 ? (
<div className="empty-state">
Keine Playlists gefunden.<br/>
Nutze die Warteschlange, um Playlists zu erstellen.
</div>
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{playlists.map(p => (
<div
key={p.id}
style={{
background: 'var(--surface0)',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
border: '1px solid var(--surface1)',
transition: 'all 0.2s ease'
}}
className="hover-card"
>
<div>
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '0 0 0.25rem 0', color: 'var(--text)' }} className="truncate">
{p.name}
</h3>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
{p.songCount} {p.songCount === 1 ? 'Track' : 'Tracks'} {Math.floor(p.duration / 60)} Min.
</p>
</div>
<div className="playlist-list">
<div className="playlist-list-header">
<div />
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
<div />
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-primary"
onClick={() => handlePlay(p.id)}
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
>
<Play size={16} fill="currentColor" /> Abspielen
</button>
<button
className="btn btn-ghost"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip="Löschen"
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
>
<Trash2 size={18} />
</button>
</div>
{visible.length === 0 ? (
<div className="empty-state">{t('playlists.noResults')}</div>
) : visible.map(p => (
<div key={p.id} className="playlist-row">
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
<Play size={14} fill="currentColor" />
</button>
<span className="playlist-name truncate">{p.name}</span>
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
<span className="playlist-meta">{formatDuration(p.duration)}</span>
<button
className="btn btn-ghost playlist-delete-btn"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip={t('playlists.deleteTooltip')}
>
<Trash2 size={15} />
</button>
</div>
))}
</div>

Some files were not shown because too many files have changed in this diff Show More