Compare commits

..

10 Commits

Author SHA1 Message Date
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
30 changed files with 2686 additions and 529 deletions
+21 -9
View File
@@ -14,9 +14,9 @@ jobs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: get version
@@ -29,16 +29,27 @@ jobs:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
with:
script: |
const tag = `app-v${process.env.PACKAGE_VERSION}`;
try {
const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
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: `app-v${process.env.PACKAGE_VERSION}`,
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body: 'See the assets to download this version and install.',
draft: false,
prerelease: false
})
return data.id
});
return data.id;
build-macos-windows:
needs: create-release
@@ -56,9 +67,9 @@ jobs:
args: ''
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: install Rust stable
@@ -80,13 +91,14 @@ jobs:
contents: write
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- 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 \
libasound2-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-libav
@@ -97,7 +109,7 @@ jobs:
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
+125
View File
@@ -5,6 +5,131 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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
+2 -3
View File
@@ -16,9 +16,6 @@ Psysonic is a beautiful desktop music player built completely from the ground up
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.
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.
![Psysonic Screenshot](public/screenshot.png)
@@ -38,6 +35,8 @@ Linux (drag & drop cursor feedback): Due to a WebKitGTK limitation, the drag cur
## ● Known Limitations
- **Seeking (all platforms)**: Seeking may occasionally be unreliable — especially on Linux/GStreamer. This is a known issue currently being worked on.
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop is being actively worked on and may not always behave correctly on macOS and Windows.
- **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
+2 -17
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "0.1.0",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "0.1.0",
"version": "1.0.12",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
@@ -16,7 +16,6 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -28,7 +27,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 +1572,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",
@@ -2110,12 +2101,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",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.8",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -19,7 +19,6 @@
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -31,7 +30,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",
+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 = "1.0.8"
version = "1.2.0"
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"] }
+3 -1
View File
@@ -25,6 +25,8 @@
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show"
"core:window:allow-show",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
]
}
+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","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}}
+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");
}
+9 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.8",
"version": "1.2.0",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -45,10 +45,18 @@
"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"
}
}
}
}
+20 -24
View File
@@ -23,10 +23,11 @@ import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import 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 }) {
@@ -65,12 +66,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(() => {
@@ -78,24 +77,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';
@@ -111,13 +104,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()}
@@ -126,14 +119,6 @@ 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 />
@@ -153,6 +138,7 @@ function AppShell() {
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
@@ -184,9 +170,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;
@@ -231,6 +223,10 @@ export default function App() {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => {
return initAudioListeners();
}, []);
return (
<BrowserRouter>
<TauriEventBridge />
+6 -2
View File
@@ -74,6 +74,8 @@ export interface SubsonicSong {
samplingRate?: number;
channelCount?: number;
starred?: string;
genre?: string;
path?: string;
}
export interface SubsonicPlaylist {
@@ -160,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 };
if (genre) params.genre = genre;
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
return data.randomSongs?.song ?? [];
}
+46 -2
View File
@@ -15,6 +15,43 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{title}
</span>
</div>
);
}
// ─── Crossfading blurred background ───────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
@@ -52,6 +89,7 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
// ─── 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);
@@ -60,6 +98,9 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
[seek]
);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
@@ -68,7 +109,10 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
/>
</div>
@@ -166,7 +210,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
</div>
<div className="fs-track-info">
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
+105 -67
View File
@@ -19,9 +19,11 @@ export default function LiveSearch() {
const [results, setResults] = useState<SearchResults | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate();
const playTrack = usePlayerStore(state => state.playTrack);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const doSearch = useCallback(
debounce(async (q: string) => {
@@ -38,7 +40,7 @@ export default function LiveSearch() {
[]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
// Close on click outside
useEffect(() => {
@@ -51,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">
@@ -69,6 +105,7 @@ export default function LiveSearch() {
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open}
@@ -82,78 +119,79 @@ export default function LiveSearch() {
</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">{t('search.noResults', { query })}</div>
)}
{results?.artists.length ? (
<div className="search-section">
<div className="search-section-label"><Users size={12} /> {t('search.artists')}</div>
{results.artists.map(a => (
<button
key={a.id}
className="search-result-item"
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
role="option"
>
<div className="search-result-icon"><Users size={14} /></div>
<span>{a.name}</span>
</button>
))}
</div>
) : null}
{(() => {
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} /> {t('search.albums')}</div>
{results.albums.map(a => (
<button
key={a.id}
className="search-result-item"
onClick={() => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); }}
role="option"
>
{a.coverArt ? (
<img className="search-result-thumb" src={buildCoverArtUrl(a.coverArt, 40)} alt="" loading="lazy" />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{a.artist}</div>
</div>
</button>
))}
</div>
) : null}
{results?.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} /> {t('search.songs')}</div>
{results.songs.map(s => (
<button
key={s.id}
className="search-result-item"
onClick={() => {
playTrack({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, 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"
>
<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>
+7 -1
View File
@@ -4,9 +4,11 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -121,7 +123,11 @@ export default function NowPlayingDropdown() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visible.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' }}>
<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' }} />
+18 -4
View File
@@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -16,7 +17,8 @@ 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, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
@@ -30,8 +32,10 @@ export default function PlayerBar() {
setVolume(parseFloat(e.target.value));
}, [setVolume]);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
const progressStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${progress * 100}%, var(--ctp-surface2) ${progress * 100}%)`,
background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`,
};
const volumeStyle = {
@@ -66,10 +70,20 @@ 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>
+48 -19
View File
@@ -1,9 +1,10 @@
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';
@@ -111,6 +112,7 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
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,6 +122,7 @@ 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 [draggedIdx, setDraggedIdx] = useState<number | null>(null);
@@ -130,6 +133,8 @@ export default function QueuePanel() {
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);
@@ -169,38 +174,48 @@ export default function QueuePanel() {
};
const onDragEnd = () => {
setDraggedIdx(null);
setDragOverIdx(null);
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
};
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Capture refs before resetting — dragend may have already cleared them on Mac.
const fromIdx = draggedIdxRef.current;
const toIdx = dragOverIdxRef.current ?? queue.length;
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
// Read dataTransfer — set during dragstart, outlives dragend on all platforms.
let parsedData: any = null;
try {
const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
// Internal reorder: prefer ref value (fast path), fall back to dataTransfer
// for the Mac dragend-before-drop race condition.
const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null);
if (reorderFrom !== null) {
if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
if (parsedData?.type === 'queue_reorder') {
// fromIdx: always reliable from dataTransfer (set during dragstart)
const fromIdx: number = parsedData.index;
// 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;
}
}
}
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
return;
}
@@ -233,6 +248,9 @@ export default function QueuePanel() {
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
<Shuffle size={14} />
</button>
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
<Save size={14} />
</button>
@@ -253,15 +271,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>
<h3
className="truncate"
data-tooltip={currentTrack.title}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.title}</h3>
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
<div className="queue-current-sub truncate" data-tooltip={currentTrack.album}>{currentTrack.album}</div>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.album}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.album}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
<div className="queue-current-tech">
@@ -279,7 +307,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')}
@@ -303,8 +331,9 @@ export default function QueuePanel() {
}
return (
<div
key={`${track.id}-${idx}`}
<div
key={`${track.id}-${idx}`}
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''}`}
onClick={() => playTrack(track, queue)}
onContextMenu={(e) => {
+8 -10
View File
@@ -1,4 +1,6 @@
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 {
@@ -22,7 +24,7 @@ const navItems = [
];
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
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;
@@ -48,14 +50,12 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<a
<button
className="update-toast-link"
href="https://github.com/Psychotoxical/psysonic/releases"
target="_blank"
rel="noopener noreferrer"
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases')}
>
{t('sidebar.updateLink')}
</a>
</button>
</div>
);
}
@@ -74,17 +74,15 @@ export default function Sidebar({
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const current = await getVersion();
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, current)) {
if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable or not running in Tauri — silently skip
// network unavailable — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
+53 -3
View File
@@ -44,6 +44,9 @@ const enTranslation = {
albums: 'Albums',
songs: 'Songs',
clearLabel: 'Clear search',
title: 'Search',
resultsFor: 'Results for "{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Who is listening?',
@@ -142,6 +145,19 @@ const enTranslation = {
favoriteAdd: 'Add to Favorites',
favoriteRemove: 'Remove from Favorites',
play: 'Play',
trackGenre: 'Genre',
excludeAudiobooks: 'Exclude audiobooks & radio plays',
excludeAudiobooksDesc: 'Filters genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
genreBlocked: 'Genre blocked',
genreAddedToBlacklist: 'Added to blacklist',
genreAlreadyBlocked: 'Already blocked',
blacklistToggle: 'Genre Blacklist',
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\nto add it to your blacklist.\nExcluded from future mixes.',
},
playlists: {
title: 'Playlists',
@@ -258,7 +274,15 @@ const enTranslation = {
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
aboutRepo: 'Source Code on GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js'
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
randomMixTitle: 'Random Mix',
randomMixBlacklistTitle: 'Custom Genre Blacklist',
randomMixBlacklistDesc: 'Songs whose genre matches an entry in this list are excluded from the Random Mix (when "Exclude audiobooks" is active).',
randomMixBlacklistPlaceholder: 'Add genre…',
randomMixBlacklistAdd: 'Add',
randomMixBlacklistEmpty: 'No custom genres added yet.',
randomMixHardcodedTitle: 'Built-in exclusions (active when checkbox is on)',
},
help: {
title: 'Help',
@@ -324,6 +348,7 @@ const enTranslation = {
delete: 'Delete',
deleteConfirm: 'Delete playlist "{{name}}"?',
clear: 'Clear',
shuffle: 'Shuffle queue',
hide: 'Hide',
close: 'Close',
nextTracks: 'Next Tracks',
@@ -401,6 +426,9 @@ const deTranslation = {
albums: 'Alben',
songs: 'Songs',
clearLabel: 'Suche leeren',
title: 'Suche',
resultsFor: 'Ergebnisse für „{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Wer hört was?',
@@ -499,6 +527,19 @@ const deTranslation = {
favoriteAdd: 'Zu Favoriten hinzufügen',
favoriteRemove: 'Aus Favoriten entfernen',
play: 'Abspielen',
trackGenre: 'Genre',
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
excludeAudiobooksDesc: 'Filtert Genres: Hörbuch, Hörspiel, Audiobook, Spoken Word, …',
genreBlocked: 'Genre gesperrt',
genreAddedToBlacklist: 'Zur Blacklist hinzugefügt',
genreAlreadyBlocked: 'Bereits gesperrt',
blacklistToggle: 'Genre-Blacklist',
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 zur Blacklist hinzuzufügen.\nBeim nächsten Mix ausgeschlossen.',
},
playlists: {
title: 'Playlists',
@@ -612,10 +653,18 @@ const deTranslation = {
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutLicense: 'Lizenz',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js'
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic',
randomMixTitle: 'Zufallsmix',
randomMixBlacklistTitle: 'Eigene Genre-Blacklist',
randomMixBlacklistDesc: 'Songs, deren Genre einem Eintrag in dieser Liste entspricht, werden aus dem Zufallsmix ausgeschlossen (wenn „Hörbücher ausschließen" aktiv ist).',
randomMixBlacklistPlaceholder: 'Genre hinzufügen…',
randomMixBlacklistAdd: 'Hinzufügen',
randomMixBlacklistEmpty: 'Noch keine eigenen Genres hinzugefügt.',
randomMixHardcodedTitle: 'Eingebaute Ausschlüsse (aktiv wenn Checkbox an)',
},
help: {
title: 'Hilfe',
@@ -681,6 +730,7 @@ const deTranslation = {
delete: 'Löschen',
deleteConfirm: 'Playlist "{{name}}" löschen?',
clear: 'Leeren',
shuffle: 'Queue mischen',
hide: 'Verbergen',
close: 'Schließen',
nextTracks: 'Nächste Titel',
+25 -13
View File
@@ -31,11 +31,10 @@ function formatSize(bytes?: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: number }): string {
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`);
if (song.samplingRate) parts.push(`${(song.samplingRate / 1000).toFixed(1)} kHz`);
return parts.join(' · ');
}
@@ -101,6 +100,7 @@ export default function AlbumDetail() {
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 [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [ratings, setRatings] = useState<Record<string, number>>({});
@@ -110,6 +110,7 @@ export default function AlbumDetail() {
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
@@ -377,10 +378,10 @@ export default function AlbumDetail() {
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{(() => {
@@ -404,7 +405,9 @@ export default function AlbumDetail() {
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => handlePlaySong(song)}
onContextMenu={(e) => {
e.preventDefault();
@@ -427,7 +430,17 @@ export default function AlbumDetail() {
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
<div
className="track-num"
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => handlePlaySong(song)}
>
{hoveredSongId === song.id
? <Play size={13} fill="currentColor" />
: 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>
@@ -436,14 +449,6 @@ export default function AlbumDetail() {
<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"
@@ -461,6 +466,13 @@ export default function AlbumDetail() {
<div className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</div>
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
{(song.suffix || song.bitRate) && (
<span className="track-codec" style={{ marginTop: 0 }}>
{codecLabel(song)}
</span>
)}
</div>
</div>
))}
</div>
+347 -12
View File
@@ -1,9 +1,35 @@
import React, { useEffect, useState } from 'react';
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, HandMetal, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
'audiobook', 'audio book', 'spoken word', 'spokenword',
'podcast', 'kapitel', 'thriller', 'krimi', 'speech',
'fantasy', 'comedy', 'literature',
];
interface SuperGenre {
id: string;
label: string;
keywords: string[];
}
const SUPER_GENRES: SuperGenre[] = [
{ id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] },
{ id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] },
{ id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] },
{ id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] },
{ id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] },
{ id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] },
{ id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] },
{ id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] },
{ id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] },
];
function formatDuration(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
@@ -18,6 +44,19 @@ export default function RandomMix() {
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(null);
// Blacklist panel state
const [blacklistOpen, setBlacklistOpen] = useState(false);
const [newGenre, setNewGenre] = useState('');
// Genre Mix state
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixMatchedGenres, setGenreMixMatchedGenres] = useState<string[]>([]);
const fetchSongs = () => {
setLoading(true);
@@ -34,10 +73,25 @@ export default function RandomMix() {
useEffect(() => {
fetchSongs();
getGenres().then(setServerGenres).catch(() => {});
}, []);
const filteredSongs = songs.filter(song => {
if (!excludeAudiobooks) return true;
const checkText = (text: string) => {
const t = text.toLowerCase();
if (AUDIOBOOK_GENRES.some(ag => t.includes(ag))) return true;
if (customGenreBlacklist.some(bg => t.includes(bg.toLowerCase()))) return true;
return false;
};
if (song.genre && checkText(song.genre)) return false;
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
return true;
});
const handlePlayAll = () => {
if (songs.length > 0) playTrack(songs[0], songs);
if (filteredSongs.length > 0) playTrack(filteredSongs[0], filteredSongs);
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
@@ -57,6 +111,71 @@ export default function RandomMix() {
}
};
// Compute which super-genres have matching server genres
const availableSuperGenres = SUPER_GENRES.filter(sg =>
serverGenres.some(sg2 =>
sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))
)
);
const loadGenreMix = async (superGenreId: string) => {
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
if (!sg) return;
const matched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value);
setGenreMixMatchedGenres(matched);
setGenreMixLoading(true);
setGenreMixSongs([]);
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
const accumulated: SubsonicSong[] = [];
let resolved = 0;
await Promise.allSettled(matched.map(g =>
getRandomSongs(perGenre, g, 45000).then(songs => {
accumulated.push(...songs);
resolved++;
// Show first batch immediately; update on every subsequent resolve
setGenreMixSongs([...accumulated]);
if (resolved === 1) setGenreMixLoading(false);
}).catch(() => { resolved++; })
));
// Final shuffle once all requests are done
setGenreMixSongs(prev => {
const s = [...prev];
for (let i = s.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[s[i], s[j]] = [s[j], s[i]];
}
return s.slice(0, 50);
});
setGenreMixLoading(false);
};
const loadMoreGenreMix = async () => {
if (!genreMixMatchedGenres.length) return;
setGenreMixLoading(true);
try {
const perGenre = Math.max(1, Math.ceil(10 / genreMixMatchedGenres.length));
const settled = await Promise.allSettled(genreMixMatchedGenres.map(g => getRandomSongs(perGenre, g, 45000)));
const combined = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
enqueue(combined.slice(0, 10).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,
})));
} finally {
setGenreMixLoading(false);
}
};
return (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
@@ -66,33 +185,208 @@ export default function RandomMix() {
<button className="btn btn-surface" onClick={fetchSongs} disabled={loading} data-tooltip={t('randomMix.remixTooltip')}>
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || songs.length === 0}>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={loading || filteredSongs.length === 0}>
<Play size={18} fill="currentColor" /> {t('randomMix.playAll')}
</button>
</div>
</div>
{loading && songs.length === 0 ? (
{/* ── Filter + Genre Mix panel ─────────────────────────────── */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1px',
background: 'var(--border)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
marginBottom: '2rem',
overflow: 'hidden',
}}>
{/* Left: Blacklist */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
<input
type="checkbox"
checked={excludeAudiobooks}
onChange={e => setExcludeAudiobooks(e.target.checked)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{t('randomMix.excludeAudiobooks')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{t('randomMix.excludeAudiobooksDesc')}</div>
</div>
</label>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '3px 8px', marginBottom: blacklistOpen ? '0.5rem' : 0 }}
onClick={() => setBlacklistOpen(v => !v)}
>
{blacklistOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{t('randomMix.blacklistToggle')} ({customGenreBlacklist.length})
</button>
{blacklistOpen && (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.5rem', minHeight: 24 }}>
{customGenreBlacklist.length === 0 ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t('settings.randomMixBlacklistEmpty')}</span>
) : (
customGenreBlacklist.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
padding: '1px 7px', fontSize: 11, fontWeight: 500,
}}>
{genre}
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 13 }}
onClick={() => setCustomGenreBlacklist(customGenreBlacklist.filter(g => g !== genre))}
>×</button>
</span>
))
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<input
className="input"
type="text"
value={newGenre}
onChange={e => setNewGenre(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newGenre.trim()) {
const trimmed = newGenre.trim();
if (!customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
setNewGenre('');
}
}}
placeholder={t('settings.randomMixBlacklistPlaceholder')}
style={{ fontSize: 12 }}
/>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => {
const trimmed = newGenre.trim();
if (trimmed && !customGenreBlacklist.includes(trimmed)) setCustomGenreBlacklist([...customGenreBlacklist, trimmed]);
setNewGenre('');
}}
disabled={!newGenre.trim()}
>{t('settings.randomMixBlacklistAdd')}</button>
</div>
</div>
)}
</div>
{/* Right: Genre Mix */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.genreMixTitle')}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{serverGenres.length === 0 ? (
<div className="spinner" style={{ width: 14, height: 14 }} />
) : availableSuperGenres.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
availableSuperGenres.map(sg => (
<button
key={sg.id}
className={`btn ${selectedSuperGenre === sg.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedSuperGenre(sg.id); loadGenreMix(sg.id); }}
disabled={genreMixLoading}
>
{sg.label}
</button>
))
)}
</div>
</div>
</div>
{/* Genre Mix tracklist (shown when a super-genre is selected) */}
{(genreMixLoading || genreMixSongs.length > 0) && (
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }} onClick={loadMoreGenreMix} disabled={genreMixLoading}>
<Plus size={14} /> {t('randomMix.genreMixLoadMore')}
</button>
<button className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }} onClick={() => genreMixSongs.length > 0 && playTrack(genreMixSongs[0], genreMixSongs)} disabled={genreMixLoading || genreMixSongs.length === 0}>
<Play size={14} fill="currentColor" /> {t('randomMix.playAll')}
</button>
</div>
</div>
{genreMixLoading && genreMixSongs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{genreMixSongs.map(song => (
<div key={song.id} className="track-row" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(song, genreMixSongs)} role="row" draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
<Play size={14} fill="currentColor" />
</button>
<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">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div>
))}
</div>
)}
</div>
)}
{!selectedSuperGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}></span>
</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{songs.map((song) => (
{filteredSongs.map((song) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(song, filteredSongs)}
role="row"
draggable
onDragStart={e => {
@@ -108,7 +402,7 @@ export default function RandomMix() {
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
onClick={(e) => { e.stopPropagation(); playTrack(song, filteredSongs); }}
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
@@ -126,6 +420,46 @@ export default function RandomMix() {
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
</div>
{(() => {
const genre = song.genre;
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}></div>;
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedGenre === genre;
return (
<button
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
}}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
setAddedGenre(genre);
setTimeout(() => setAddedGenre(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
>
{genre}
</button>
);
})()}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
@@ -133,7 +467,7 @@ export default function RandomMix() {
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<HandMetal size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
</div>
@@ -143,7 +477,8 @@ export default function RandomMix() {
</div>
))}
</div>
)}
))}
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Play, Search } from 'lucide-react';
import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
}
export default function SearchResults() {
const { t } = useTranslation();
const [params] = useSearchParams();
const query = params.get('q') ?? '';
const [results, setResults] = useState<ISearchResults | null>(null);
const [loading, setLoading] = useState(false);
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
setLoading(true);
search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
.then(r => setResults(r))
.finally(() => setLoading(false));
}, [query]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
playTrack({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
}, list.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, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating,
})));
};
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div style={{ marginBottom: '-1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Search size={22} />
{query ? t('search.resultsFor', { query }) : t('search.title')}
</h1>
</div>
{loading && (
<div className="loading-center"><div className="spinner" /></div>
)}
{!loading && query && !hasResults && (
<div className="empty-state">{t('search.noResults', { query })}</div>
)}
{!loading && results && (
<>
{results.artists.length > 0 && (
<ArtistRow title={t('search.artists')} artists={results.artists} />
)}
{results.albums.length > 0 && (
<AlbumRow title={t('search.albums')} albums={results.albums} />
)}
{results.songs.length > 0 && (
<section className="album-row-section">
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
<div />
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div>{t('search.album')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
</div>
{results.songs.map(song => (
<div
key={song.id}
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playSong(song, results.songs); }}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" title={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
<span className="track-codec" style={{ alignSelf: 'center' }}>
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
))}
</div>
</section>
)}
</>
)}
</div>
);
}
+97 -2
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle
} from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { useAuthStore, ServerProfile } from '../store/authStore';
@@ -10,6 +10,8 @@ import { pingWithCredentials } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
const [form, setForm] = useState({ name: '', url: '', username: '', password: '' });
@@ -76,6 +78,7 @@ export default function Settings() {
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
@@ -353,6 +356,94 @@ export default function Settings() {
</div>
</section>
{/* Random Mix */}
<section className="settings-section">
<div className="settings-section-header">
<Shuffle size={18} />
<h2>{t('settings.randomMixTitle')}</h2>
</div>
<div className="settings-card">
{/* Description */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.randomMixBlacklistDesc')}
</p>
{/* Custom blacklist chips */}
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
{auth.customGenreBlacklist.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
) : (
auth.customGenreBlacklist.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
padding: '2px 8px', fontSize: 12, fontWeight: 500,
}}>
{genre}
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
aria-label={`Remove ${genre}`}
>×</button>
</span>
))
)}
</div>
{/* Add input */}
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
<input
className="input"
type="text"
value={newGenre}
onChange={e => setNewGenre(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newGenre.trim()) {
const trimmed = newGenre.trim();
if (!auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}
}}
placeholder={t('settings.randomMixBlacklistPlaceholder')}
style={{ fontSize: 13 }}
/>
<button
className="btn btn-ghost"
onClick={() => {
const trimmed = newGenre.trim();
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
}
setNewGenre('');
}}
disabled={!newGenre.trim()}
>
{t('settings.randomMixBlacklistAdd')}
</button>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
{/* Hardcoded list */}
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} style={{
display: 'inline-flex', alignItems: 'center',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
}}>
{genre}
</span>
))}
</div>
</div>
</section>
{/* Logout */}
<section className="settings-section">
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={handleLogout} id="settings-logout-btn">
@@ -374,7 +465,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.8
{t('settings.aboutVersion')} 1.0.12
</div>
</div>
</div>
@@ -397,6 +488,10 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
</div>
<button
+8
View File
@@ -25,6 +25,8 @@ interface AuthState {
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
// Status
isLoggedIn: boolean;
@@ -44,6 +46,8 @@ interface AuthState {
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
logout: () => void;
// Derived
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -109,6 +115,8 @@ export const useAuthStore = create<AuthState>()(
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
logout: () => set({ isLoggedIn: false }),
+345 -277
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { Howl } from 'howler';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, getPlayQueue, savePlayQueue, SubsonicSong, reportNowPlaying, scrobbleSong } from '../api/subsonic';
import { useAuthStore } from './authStore';
@@ -26,13 +27,11 @@ interface PlayerState {
queueIndex: number;
isPlaying: boolean;
progress: number; // 01
buffered: number; // 01 (unused in Rust backend, kept for UI compat)
currentTime: number;
volume: number;
howl: Howl | null;
prefetched: Map<string, Howl>;
scrobbled: boolean;
// Actions
playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void;
resume: () => void;
@@ -45,63 +44,135 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
clearQueue: () => void;
prefetchUpcoming: (fromIndex: number, queue: Track[]) => void;
isQueueVisible: boolean;
toggleQueue: () => void;
isFullscreenOpen: boolean;
toggleFullscreen: () => void;
repeatMode: 'off' | 'all' | 'one';
toggleRepeat: () => void;
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
shuffleQueue: () => void;
initializeFromServerQueue: () => Promise<void>;
// Context Menu Global State
contextMenu: {
isOpen: boolean;
x: number;
y: number;
item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
queueIndex?: number; // Only for 'queue-item'
queueIndex?: number;
};
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
closeContextMenu: () => void;
}
let progressInterval: ReturnType<typeof setInterval> | null = null;
// ─── Module-level playback primitives ─────────────────────────────────────────
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
// Used by resume() to decide between audio_resume (warm) vs audio_play (cold start).
let isAudioPaused = false;
// JS-side generation counter. Incremented on every playTrack() call.
// The invoke().catch() error handler captures its own gen and bails if
// playGeneration has moved on, preventing stale errors from skipping wrong tracks.
let playGeneration = 0;
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Blocks progress interval from overwriting the UI position while GStreamer
// processes a seek on the HTTP pipeline (can take several hundred ms on Linux).
let isSeeking = false;
function clearProgress() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
}
// Guard against rapid double-click play/pause sending two state transitions
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// Helper to debounce or fire queue syncs
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
// Collect up to 1000 track IDs just in case it's huge
const ids = queue.slice(0, 1000).map(t => t.id);
// Convert currentTime (seconds) to expected format (milliseconds)
const pos = Math.floor(currentTime * 1000);
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
console.error('Failed to sync play queue to server', err);
});
}, 1500); // 1.5s debounce
}, 1500);
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {
usePlayerStore.setState({ isPlaying: true });
}
function handleAudioProgress(current_time: number, duration: number) {
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = current_time / dur;
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
// Scrobble at 50%
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
}
function handleAudioEnded() {
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue);
} else {
usePlayerStore.getState().next();
}
}, 150);
}
function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false;
const gen = playGeneration;
usePlayerStore.setState({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
usePlayerStore.getState().next();
}, 500);
}
/**
* Set up Tauri event listeners for the Rust audio engine.
* Returns a cleanup function pass it to useEffect's return value so that
* React StrictMode (which double-invokes effects in dev) tears down the first
* set of listeners before creating the second, avoiding duplicate handlers.
*/
export function initAudioListeners(): () => void {
const pending = [
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) =>
handleAudioProgress(payload.current_time, payload.duration)
),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
];
return () => {
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
// ─── Store ────────────────────────────────────────────────────────────────────
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => ({
@@ -110,10 +181,9 @@ export const usePlayerStore = create<PlayerState>()(
queueIndex: 0,
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
volume: 0.8,
howl: null,
prefetched: new Map(),
scrobbled: false,
isQueueVisible: true,
isFullscreenOpen: false,
@@ -121,272 +191,270 @@ export const usePlayerStore = create<PlayerState>()(
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex }
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false }
contextMenu: { ...state.contextMenu, isOpen: false },
})),
toggleQueue: () => set((state) => ({ isQueueVisible: !state.isQueueVisible })),
toggleFullscreen: () => set((state) => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleRepeat: () => set((state) => {
const modes = ['off', 'all', 'one'] as const;
const nextIdx = (modes.indexOf(state.repeatMode) + 1) % modes.length;
return { repeatMode: modes[nextIdx] };
}),
toggleRepeat: () => set(state => {
const modes = ['off', 'all', 'one'] as const;
return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
}),
stop: () => {
get().howl?.stop();
get().howl?.seek(0);
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
},
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
},
playTrack: (track, queue) => {
const state = get();
// Stop current
state.howl?.unload();
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
isSeeking = false;
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
const gen = ++playGeneration;
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
// Set state immediately so the UI updates before the download completes.
set({
currentTrack: track,
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: 0,
buffered: 0,
currentTime: 0,
scrobbled: false,
isPlaying: true, // optimistic — reverted on error
});
const url = buildStreamUrl(track.id);
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
get().next();
}, 500);
});
const url = buildStreamUrl(track.id);
const howl = new Howl({
src: [url],
html5: true,
volume: state.volume,
onplay: () => {
set({ isPlaying: true });
// Subsonic / Navidrome Now Playing
reportNowPlaying(track.id);
set({ scrobbled: false });
progressInterval = setInterval(() => {
const h = get().howl;
if (!h) return;
// Skip position updates while a seek is in flight — GStreamer may
// still report the old position and would overwrite the UI value.
if (isSeeking) return;
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
const dur = h.duration() || 1;
const prog = cur / dur;
set({ currentTime: cur, progress: prog });
// Scrobble at 50%
if (prog >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) {
scrobbleSong(track.id, Date.now());
}
}
}, 500);
// Prefetch next 3
get().prefetchUpcoming(idx + 1, newQueue);
syncQueueToServer(newQueue, track, 0);
},
onend: () => {
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, queue);
} else {
get().next();
}
},
onstop: () => {
clearProgress();
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
set({ isPlaying: false });
},
});
howl.play();
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
syncQueueToServer(newQueue, track, 0);
},
resume: () => {
const { currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
pause: () => {
get().howl?.pause();
clearProgress();
set({ isPlaying: false });
},
resume: () => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
howl.play();
set({ isPlaying: true });
},
togglePlay: () => {
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
next: () => {
const { queue, queueIndex, repeatMode } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
}
},
previous: () => {
const { howl, queue, queueIndex, currentTime } = get();
if (currentTime > 3) {
howl?.seek(0);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
seek: (progress) => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
// Update UI immediately and block interval from overwriting it.
set({ progress, currentTime: time });
isSeeking = true;
// Debounce so rapid slider drags collapse into one seek.
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
const h = get().howl;
if (!h) { isSeeking = false; seekDebounce = null; return; }
// GStreamer HTTP pipelines require the element to be paused before a
// seek is accepted reliably. Seek while playing can silently fail on
// Linux (tested on Mint + CachyOS), leaving the pipeline at the old
// position on the next seek.
const wasPlaying = h.playing();
if (wasPlaying) h.pause();
h.seek(time);
// Resume after GStreamer has had time to flush and re-buffer.
seekDebounce = setTimeout(() => {
const h2 = get().howl;
if (h2 && wasPlaying) h2.play();
isSeeking = false;
seekDebounce = null;
}, 300);
}, 100);
},
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
get().howl?.volume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
},
clearQueue: () => {
get().howl?.unload();
clearProgress();
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, currentTime: 0, howl: null });
syncQueueToServer([], null, 0);
},
// Internal: prefetch next N tracks
prefetchUpcoming: (fromIndex: number, queue: Track[]) => {
const { prefetched } = get();
// Unload and clear old prefetches to prevent memory leaks
prefetched.forEach((h, id) => {
h.unload();
});
prefetched.clear();
const toFetch = queue.slice(fromIndex, fromIndex + 3);
toFetch.forEach(track => {
const url = buildStreamUrl(track.id);
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
prefetched.set(track.id, h);
});
set({ prefetched: new Map(prefetched) });
},
// Playlist management
reorderQueue: (startIndex: number, endIndex: number) => {
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
// Update queueIndex if the currently playing track moved
let newIndex = queueIndex;
if (currentTrack) {
newIndex = result.findIndex(t => t.id === currentTrack.id);
}
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index: number) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
// If we removed the currently playing track, stop playback?
// Usually wait until it finishes or user skips. We'll just update state.
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
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,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) {
currentTrack = mappedTracks[idx];
queueIndex = idx;
}
if (isAudioPaused) {
// Rust engine has audio loaded but paused — just resume it.
invoke('audio_resume').catch(console.error);
isAudioPaused = false;
set({ isPlaying: true });
} else {
// Cold start (app relaunch) — audio is not loaded in Rust; re-download.
const gen = ++playGeneration;
const vol = get().volume;
set({ isPlaying: true });
invoke('audio_play', {
url: buildStreamUrl(currentTrack.id),
volume: vol,
durationHint: currentTrack.duration,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queue, currentTrack, currentTime);
}
set({
queue: mappedTracks,
queueIndex,
currentTrack,
// Convert position from ms to s
currentTime: q.position ? q.position / 1000 : 0
},
togglePlay: () => {
if (togglePlayLock) return;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
// ── next / previous ──────────────────────────────────────────────────────
next: () => {
const { queue, queueIndex, repeatMode } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
} else {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}
},
previous: () => {
const { queue, queueIndex, currentTime } = get();
if (currentTime > 3) {
// Restart current track from the beginning.
invoke('audio_seek', { seconds: 0 }).catch(console.error);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
// ── seek ─────────────────────────────────────────────────────────────────
// 100 ms debounce collapses rapid slider drags into one actual seek.
seek: (progress) => {
const { currentTrack } = get();
if (!currentTrack) return;
const dur = currentTrack.duration;
if (!dur || !isFinite(dur)) return;
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
set({ progress: time / dur, currentTime: time });
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
invoke('audio_seek', { seconds: time }).catch(console.error);
}, 100);
},
// ── volume ───────────────────────────────────────────────────────────────
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
// ── queue management ─────────────────────────────────────────────────────
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
},
clearQueue: () => {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
shuffleQueue: () => {
const { queue, currentTrack } = get();
if (queue.length < 2) return;
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
const others = queue.filter((_, i) => i !== currentIdx);
for (let i = others.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[others[i], others[j]] = [others[j], others[i]];
}
const result = currentIdx >= 0
? [queue[currentIdx], ...others]
: others;
const newIndex = currentIdx >= 0 ? 0 : -1;
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
// ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
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,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
}),
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
currentTrack: state.currentTrack,
queue: state.queue,
queueIndex: state.queueIndex,
currentTime: state.currentTime,
} as Partial<PlayerState>),
}
},
}), {
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
} as Partial<PlayerState>),
}));
)
);
+57 -23
View File
@@ -4,14 +4,14 @@
.hero {
position: relative;
width: 100%;
height: 300px;
height: 360px;
overflow: hidden;
flex-shrink: 0;
}
.hero-placeholder {
width: 100%;
height: 300px;
height: 360px;
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
flex-shrink: 0;
}
@@ -23,7 +23,9 @@
background-position: center;
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05);
filter: blur(12px);
}
.hero:hover .hero-bg { filter: blur(8px); }
.hero:hover .hero-bg { transform: scale(1); }
.hero-dots {
@@ -74,8 +76,8 @@
}
.hero-cover {
width: 180px;
height: 180px;
width: 220px;
height: 220px;
border-radius: var(--radius-md);
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
object-fit: cover;
@@ -436,7 +438,8 @@
transition: background var(--transition-fast);
border-radius: 0;
}
.search-result-item:hover { background: var(--bg-hover); }
.search-result-item:hover,
.search-result-item.active { background: var(--bg-hover); }
.search-result-icon { width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--bg-hover); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; }
.search-result-thumb { width: 32px; height: 32px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
.search-result-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
@@ -567,8 +570,9 @@
.tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
font-size: 11px;
font-weight: 600;
@@ -579,12 +583,12 @@
margin-bottom: var(--space-2);
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.track-row {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
align-items: start;
padding: var(--space-2) var(--space-3);
@@ -593,37 +597,46 @@
transition: background var(--transition-fast);
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.tracklist-total {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
border-top: 1px solid var(--border-subtle);
padding: var(--space-3) var(--space-4);
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.tracklist-total-label {
grid-column: 1 / -2;
grid-column: 1 / 5;
text-align: right;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-right: var(--space-3);
}
.tracklist-total-value {
grid-column: 5 / 6;
text-align: right;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; }
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
.track-row:hover { background: var(--bg-hover); }
.track-row.active { background: var(--accent-dim); animation: track-pulse 2.5s ease-in-out infinite; }
.track-row.active:hover { background: var(--accent-dim); animation: none; }
@keyframes track-pulse {
0%, 100% { background: var(--accent-dim); }
50% { background: transparent; }
}
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
/* CD / Disc separator */
@@ -930,13 +943,22 @@
50% { transform: scale(1.018); }
}
@keyframes ken-burns {
0% { transform: scale(1.08) translate(0%, 0%); }
25% { transform: scale(1.12) translate(-1.5%, 1%); }
50% { transform: scale(1.10) translate(1%, -1.5%); }
75% { transform: scale(1.13) translate(1.5%, 0.5%); }
100% { transform: scale(1.08) translate(0%, 0%); }
}
/* ── Blurred background ── */
.fs-bg {
position: absolute;
inset: -10%;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(6px) brightness(0.25) saturate(1.6);
animation: ken-burns 40s ease-in-out infinite;
transform: scale(1.2);
z-index: 0;
will-change: opacity;
@@ -1036,10 +1058,7 @@
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow:
0 30px 90px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.07),
0 0 80px rgba(0, 0, 0, 0.4);
box-shadow: none;
flex-shrink: 0;
animation: cover-breathe 9s ease-in-out infinite;
}
@@ -1064,7 +1083,7 @@
text-align: center;
width: 100%;
}
.fs-title {
.fs-title-wrap {
font-family: var(--font-display);
font-size: clamp(20px, 3vw, 32px);
font-weight: 800;
@@ -1073,7 +1092,14 @@
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-title-marquee {
display: inline-block;
animation: marquee-scroll 14s linear infinite alternate;
}
@keyframes marquee-scroll {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(var(--scroll-amount, 0px)); }
}
.fs-album {
font-size: 14px;
@@ -1119,8 +1145,10 @@
height: 3px;
background: linear-gradient(
to right,
rgba(255,255,255,0.85) var(--pct),
rgba(255,255,255,0.15) var(--pct)
rgba(255,255,255,0.85) var(--pct, 0%),
rgba(255,255,255,0.32) var(--pct, 0%),
rgba(255,255,255,0.32) var(--buf, 0%),
rgba(255,255,255,0.15) var(--buf, 0%)
);
border-radius: 2px;
cursor: pointer;
@@ -1388,6 +1416,12 @@
visibility: visible;
}
[data-tooltip-wrap]::after {
white-space: pre-line;
max-width: 220px;
text-align: left;
}
/* Modifiers for position */
[data-tooltip][data-tooltip-pos="bottom"]::before {
top: 100%;
+15 -9
View File
@@ -29,7 +29,6 @@
background: var(--accent);
}
.resizer-sidebar { left: calc(var(--sidebar-width) - 3px); }
.resizer-queue { right: calc(var(--queue-width) - 3px); }
/* ─── Sidebar ─── */
@@ -236,6 +235,7 @@
overflow: hidden;
min-width: 0;
background: var(--bg-app);
z-index: 1;
}
.content-header {
@@ -370,7 +370,8 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
gap: 2px;
padding: 12px 0;
}
.player-buttons {
@@ -507,23 +508,26 @@
}
.queue-current-track {
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-cover {
width: 100%;
aspect-ratio: 1 / 1;
width: 72px;
height: 72px;
flex-shrink: 0;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-surface);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.queue-current-cover img {
@@ -540,10 +544,12 @@
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.queue-current-info h3 {
font-size: 16px;
font-size: 13px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
@@ -567,7 +573,7 @@
}
.queue-divider {
padding: var(--space-4) var(--space-5) 0;
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
}
+2 -2
View File
@@ -580,8 +580,8 @@ body.is-dragging * {
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--ctp-surface0);
border: 1px solid var(--border);
background: var(--ctp-base);
border: 1px solid var(--ctp-overlay0);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);