mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bdd433a4b | |||
| a5fd70d3eb | |||
| 744775d3a1 | |||
| 9b1f3b019f | |||
| baa701dd74 |
@@ -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
|
||||
@@ -87,6 +98,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
|
||||
libasound2-dev \
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
|
||||
|
||||
+70
-2
@@ -5,6 +5,74 @@ 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
|
||||
@@ -18,8 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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. Being actively investigated.
|
||||
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows. Being actively worked on.
|
||||
- ~~**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
|
||||
|
||||
|
||||
Generated
+2
-17
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.11",
|
||||
"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",
|
||||
|
||||
Generated
+840
-22
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.11"
|
||||
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"] }
|
||||
|
||||
@@ -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.5–1 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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.0.11",
|
||||
"version": "1.2.0",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+6
-2
@@ -27,7 +27,7 @@ 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 }) {
|
||||
@@ -110,7 +110,7 @@ function AppShell() {
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
|
||||
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)',
|
||||
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
@@ -223,6 +223,10 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<TauriEventBridge />
|
||||
|
||||
+6
-2
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
|
||||
+105
-73
@@ -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,12 +105,7 @@ export default function LiveSearch() {
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => results && setOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && query.trim()) {
|
||||
setOpen(false);
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results"
|
||||
aria-expanded={open}
|
||||
@@ -88,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>
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -122,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);
|
||||
@@ -132,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);
|
||||
|
||||
@@ -171,47 +174,48 @@ export default function QueuePanel() {
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reset visual state immediately.
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
// Delay clearing refs so onDropQueue can read them first.
|
||||
// On macOS WKWebView and Windows WebView2, dragend fires before drop
|
||||
// (spec violation), so synchronously clearing refs here loses the
|
||||
// drag source/destination indices before onDropQueue runs.
|
||||
setTimeout(() => {
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
}, 200);
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Refs are still valid here — onDragEnd delays clearing them so they survive
|
||||
// the macOS/WebView2 dragend-before-drop race condition.
|
||||
const fromIdx = draggedIdxRef.current;
|
||||
const toIdx = dragOverIdxRef.current ?? queue.length;
|
||||
|
||||
// Cancel the pending timeout cleanup and clear refs immediately.
|
||||
// Clear visual state immediately
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
|
||||
// Read dataTransfer — set during dragstart, survives dragend on all platforms.
|
||||
// Used as fallback for fromIdx in case refs somehow weren't set.
|
||||
let parsedData: any = null;
|
||||
try {
|
||||
const raw = e.dataTransfer.getData('text/plain');
|
||||
if (raw) parsedData = JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Internal reorder: refs are the primary source; dataTransfer is the fallback.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -244,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>
|
||||
@@ -300,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')}
|
||||
@@ -324,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) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -49,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>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-4
@@ -145,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',
|
||||
@@ -261,8 +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',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic'
|
||||
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',
|
||||
@@ -328,6 +348,7 @@ const enTranslation = {
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete playlist "{{name}}"?',
|
||||
clear: 'Clear',
|
||||
shuffle: 'Shuffle queue',
|
||||
hide: 'Hide',
|
||||
close: 'Close',
|
||||
nextTracks: 'Next Tracks',
|
||||
@@ -506,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',
|
||||
@@ -622,8 +656,15 @@ const deTranslation = {
|
||||
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
|
||||
aboutRepo: 'Quellcode auf GitHub',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js',
|
||||
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic'
|
||||
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',
|
||||
@@ -689,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',
|
||||
|
||||
+346
-11
@@ -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, Star, 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"
|
||||
@@ -143,7 +477,8 @@ export default function RandomMix() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+93
-2
@@ -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.11
|
||||
{t('settings.aboutVersion')} 1.0.12
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
+326
-308
@@ -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; // 0–1
|
||||
buffered: number; // 0–1
|
||||
buffered: number; // 0–1 (unused in Rust backend, kept for UI compat)
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
howl: Howl | null;
|
||||
scrobbled: boolean;
|
||||
|
||||
// Actions
|
||||
playTrack: (track: Track, queue?: Track[]) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
@@ -51,71 +50,129 @@ interface PlayerState {
|
||||
|
||||
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;
|
||||
let gstSeeking = false; // true while GStreamer is processing a seek
|
||||
let pendingSeekTime: number | null = null; // queue at most one seek
|
||||
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires
|
||||
let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here
|
||||
let hangLastTime = -1; // last observed currentTime for hang detection
|
||||
let hangStallTime = 0; // Date.now() when currentTime last moved
|
||||
|
||||
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;
|
||||
|
||||
function armGstWatchdog(cb: () => void) {
|
||||
if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog);
|
||||
gstSeekWatchdog = setTimeout(() => {
|
||||
gstSeekWatchdog = null;
|
||||
cb();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function disarmGstWatchdog() {
|
||||
if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; }
|
||||
}
|
||||
|
||||
// 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) => ({
|
||||
@@ -127,7 +184,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
howl: null,
|
||||
scrobbled: false,
|
||||
isQueueVisible: true,
|
||||
isFullscreenOpen: false,
|
||||
@@ -135,308 +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, buffered: 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; }
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = 0;
|
||||
// ── 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);
|
||||
|
||||
const howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume });
|
||||
// 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
|
||||
});
|
||||
|
||||
howl.on('play', () => {
|
||||
set({ isPlaying: true });
|
||||
reportNowPlaying(track.id);
|
||||
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);
|
||||
});
|
||||
|
||||
// If recovering from a pipeline hang, seek to the saved position
|
||||
if (hangRecoveryPos !== null) {
|
||||
const pos = hangRecoveryPos;
|
||||
hangRecoveryPos = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
|
||||
setTimeout(() => { howl.seek(pos); }, 50);
|
||||
}
|
||||
reportNowPlaying(track.id);
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
set({ scrobbled: false });
|
||||
hangStallTime = Date.now();
|
||||
hangLastTime = -1;
|
||||
// ── pause / resume / togglePlay ──────────────────────────────────────────
|
||||
pause: () => {
|
||||
invoke('audio_pause').catch(console.error);
|
||||
isAudioPaused = true;
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
const h = get().howl;
|
||||
if (!h) return;
|
||||
const s = h.seek();
|
||||
const cur = typeof s === 'number' ? s : 0;
|
||||
const dur = h.duration() || 1;
|
||||
const prog = cur / dur;
|
||||
resume: () => {
|
||||
const { currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
|
||||
// Read buffered ranges from the underlying <audio> element
|
||||
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||
if (audioNode?.buffered && audioNode.duration > 0) {
|
||||
let totalBuf = 0;
|
||||
for (let i = 0; i < audioNode.buffered.length; i++) {
|
||||
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
||||
}
|
||||
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
||||
if (isAudioPaused) {
|
||||
// Rust engine has audio loaded but paused — just resume it.
|
||||
invoke('audio_resume').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
set({ isPlaying: true });
|
||||
} else {
|
||||
set({ currentTime: cur, progress: prog });
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
|
||||
if (Math.abs(cur - hangLastTime) > 0.05) {
|
||||
hangLastTime = cur;
|
||||
hangStallTime = Date.now();
|
||||
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) {
|
||||
hangRecoveryPos = cur;
|
||||
hangStallTime = Date.now(); // prevent re-trigger while recovering
|
||||
get().playTrack(ct, q);
|
||||
}
|
||||
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);
|
||||
},
|
||||
|
||||
// Scrobble at 50%
|
||||
if (prog >= 0.5 && !get().scrobbled) {
|
||||
set({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
}
|
||||
}, 500);
|
||||
// ── 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 });
|
||||
},
|
||||
|
||||
howl.on('end', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
const { repeatMode, currentTrack, queue } = get();
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
get().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
get().next();
|
||||
}
|
||||
});
|
||||
setProgress: (t, duration) => {
|
||||
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
|
||||
},
|
||||
|
||||
howl.on('stop', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
|
||||
howl.on('seek', () => {
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = Date.now();
|
||||
if (pendingSeekTime !== null) {
|
||||
const t = pendingSeekTime;
|
||||
pendingSeekTime = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
|
||||
// ── queue management ─────────────────────────────────────────────────────
|
||||
enqueue: (tracks) => {
|
||||
set(state => {
|
||||
const newQueue = [...state.queue, ...tracks];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
get().howl?.seek(t);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
|
||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
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);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
get().howl?.pause();
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
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);
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
const { howl, currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
if (!howl) {
|
||||
// Cold start from restored state (e.g. app relaunch) — resume from saved position
|
||||
if (currentTime > 0) hangRecoveryPos = currentTime;
|
||||
get().playTrack(currentTrack, queue);
|
||||
return;
|
||||
}
|
||||
howl.play();
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
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);
|
||||
},
|
||||
|
||||
togglePlay: () => {
|
||||
const { isPlaying } = get();
|
||||
isPlaying ? get().pause() : get().resume();
|
||||
},
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
// ── 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,
|
||||
}));
|
||||
|
||||
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);
|
||||
},
|
||||
let currentTrack = mappedTracks[0];
|
||||
let queueIndex = 0;
|
||||
|
||||
seek: (progress) => {
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
const time = progress * (howl.duration() || currentTrack.duration);
|
||||
set({ progress, currentTime: time });
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
if (gstSeeking) {
|
||||
// GStreamer busy — queue this position; onseek will send it when ready
|
||||
pendingSeekTime = time;
|
||||
return;
|
||||
}
|
||||
gstSeeking = true;
|
||||
const seekTarget = time;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
|
||||
});
|
||||
get().howl?.seek(time);
|
||||
}, 100);
|
||||
},
|
||||
if (q.current) {
|
||||
const idx = mappedTracks.findIndex(t => t.id === q.current);
|
||||
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
|
||||
}
|
||||
|
||||
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, buffered: 0, currentTime: 0, howl: null });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
// 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;
|
||||
// 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);
|
||||
}
|
||||
|
||||
set({
|
||||
queue: mappedTracks,
|
||||
queueIndex,
|
||||
currentTrack,
|
||||
// Convert position from ms to s
|
||||
currentTime: q.position ? q.position / 1000 : 0
|
||||
});
|
||||
}
|
||||
} 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>),
|
||||
}));
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
@@ -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); }
|
||||
@@ -1413,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%;
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
background: var(--bg-app);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
|
||||
Reference in New Issue
Block a user