mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(audio): rust track preview engine + inline play/preview buttons (#392)
* feat(audio): rust preview engine with secondary sink Adds a parallel rodio Sink on the existing OutputStream for 30s mid-track previews. Two new Tauri commands (audio_preview_play, audio_preview_stop) plus three events (audio:preview-start / -progress / -end). The main sink is paused with Sink::pause() and auto-resumed on preview end iff it was playing beforehand. * feat(playlists): migrate suggestion preview to rust audio engine Replaces the HTML5 <audio> path with the new rust preview engine. previewStore mirrors the engine's start/progress/end events so any tracklist row can render preview UI from a single source of truth. Spacebar redirects to stopPreview while a preview plays, hardware mediakeys are silently dropped (Q5), and tray clicks cancel the preview before forwarding the original action. * feat(albums): inline play + preview buttons in tracklist rows Track number stays static on hover instead of swapping to a play icon — the dedicated Play and Preview buttons in the title cell take over click-to-play and click-to-preview. Active+playing rows keep the eq-bars (also on hover), active+paused rows fall back to the static accent-coloured number. Pilot for the wider rollout to other tracklists. * feat(tracklists): roll out inline play + preview buttons Mirrors the AlbumTrackList pilot across the remaining track-row based lists: PlaylistDetail main tracks, Favorites, ArtistDetail top tracks, RandomMix (both genre-mix and filtered-songs lists). Track number stays static, the dedicated Play + Preview buttons in the title cell take over click-to-play and click-to-preview. * feat(settings): track preview toggle + configurable position and duration Adds an opt-out switch and two sliders to Settings → Audio: start position (0-90 % of track length, default 33 %) and preview duration (5-60 s, default 30 s). The progress-ring animation follows the duration via a CSS variable so the visual matches the engine's auto-stop. Disabling the feature hides every inline preview button via a single root-level data attribute, no per-row conditional rendering required. i18n keys added in all 8 locales. * fix(audio): cancel preview when main playback (re)starts audio_play, audio_play_radio, audio_resume and audio_stop did not know about the parallel preview sink, so clicking Play on a track that was currently being previewed left the preview running on top of the freshly started main playback. New helper clears the resume flag, bumps the preview generation, drops the sink and emits an 'interrupted' end event before any of those commands touches the main sink. * feat(settings): per-location track preview toggles Splits the single trackPreviewsEnabled toggle into a master + 6 per-location sub-toggles (suggestions, albums, playlists, favorites, artist, randomMix). Master remains the kill switch; sub-toggles are only honoured when master is on. Each tracklist container is marked with `data-preview-loc="<id>"` and hidden via scoped CSS when the matching root attribute is "off". startPreview now takes a location argument so the store can guard logic too. i18n added in all 8 locales. * fix(contextmenu): use ChevronsRight for Play Next to distinguish from preview
This commit is contained in:
committed by
GitHub
parent
ff47170736
commit
a14dba8167
+302
-1
@@ -2290,6 +2290,19 @@ pub struct AudioEngine {
|
||||
/// decode (ALSA underruns). The ranged task clears this on exit; `gen` avoids a
|
||||
/// late drop clearing a newer play of the same track.
|
||||
pub(crate) ranged_loudness_seed_hold: Arc<Mutex<Option<(String, u64)>>>,
|
||||
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
|
||||
/// as the main sink (rodio mixes both internally) so we don't open a second
|
||||
/// device handle — important on ALSA-exclusive hardware.
|
||||
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Sink>>>>,
|
||||
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
|
||||
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
|
||||
pub(crate) preview_gen: Arc<AtomicU64>,
|
||||
/// True when `audio_preview_play` paused the main sink and should resume it
|
||||
/// on preview end. False if the main sink was already paused (or empty).
|
||||
pub(crate) preview_main_resume: Arc<AtomicBool>,
|
||||
/// Subsonic song id of the currently playing preview. Echoed back in
|
||||
/// `audio:preview-end` so the frontend can clear UI state for that row.
|
||||
pub(crate) preview_song_id: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -2565,6 +2578,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
|
||||
preview_sink: Arc::new(Mutex::new(None)),
|
||||
preview_gen: Arc::new(AtomicU64::new(0)),
|
||||
preview_main_resume: Arc::new(AtomicBool::new(false)),
|
||||
preview_song_id: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -3146,6 +3163,10 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any active preview before starting fresh main playback so the
|
||||
// two sinks don't end up mixed.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
|
||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||
// advance. The source is live in the queue — just return and let the
|
||||
@@ -4198,6 +4219,10 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// If a preview is running, cancel it first — otherwise sink.play() on the
|
||||
// main sink would mix on top of the preview sink.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Detect radio hard-disconnect.
|
||||
let reconnect_info = {
|
||||
let guard = state.radio_state.lock().unwrap();
|
||||
@@ -4256,7 +4281,8 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||
@@ -4604,6 +4630,9 @@ pub async fn audio_play_radio(
|
||||
) -> Result<(), String> {
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any active preview so it doesn't keep playing alongside radio.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Abort any previous radio task before stopping the sink.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
|
||||
@@ -5125,3 +5154,275 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct PreviewProgressPayload {
|
||||
id: String,
|
||||
elapsed: f64,
|
||||
duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct PreviewEndPayload {
|
||||
id: String,
|
||||
/// "natural" = 30 s timer / source ended; "user" = explicit stop;
|
||||
/// "interrupted" = a new preview superseded this one.
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
/// Pause main sink and remember whether to resume it after preview ends.
|
||||
/// Mirrors `audio_pause` semantics so progress timestamps stay consistent.
|
||||
fn preview_pause_main(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() && !sink.empty() {
|
||||
let pos = cur.position();
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
state.preview_main_resume.store(true, Ordering::Release);
|
||||
} else {
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel any active preview and clear the resume marker. Called from every
|
||||
/// command that brings the main sink back to life under its own steam
|
||||
/// (`audio_play`, `audio_play_radio`, `audio_resume`) — without this the
|
||||
/// preview would keep playing in parallel and the watchdog would later try
|
||||
/// to resume a main sink that's already running, double-mixing the audio.
|
||||
fn preview_clear_for_new_main_playback(state: &AudioEngine, app: &AppHandle) {
|
||||
// Order matters: clear the resume marker BEFORE bumping the generation
|
||||
// so the watchdog — if it wakes between our writes — sees no work to do
|
||||
// and bails without resuming main behind our back.
|
||||
state.preview_main_resume.store(false, Ordering::Release);
|
||||
state.preview_gen.fetch_add(1, Ordering::SeqCst);
|
||||
let sink = state.preview_sink.lock().unwrap().take();
|
||||
let id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = sink { s.stop(); }
|
||||
if let Some(id) = id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id,
|
||||
reason: "interrupted",
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume main sink iff `preview_pause_main` paused it. No-op if main was
|
||||
/// already paused/empty before preview started.
|
||||
fn preview_resume_main(state: &AudioEngine) {
|
||||
if !state.preview_main_resume.swap(false, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
url: String,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gen = state.preview_gen.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Tear down any existing preview before pausing main (so a rapid preview
|
||||
// swap doesn't double-pause and double-resume the main sink).
|
||||
let prev_sink = state.preview_sink.lock().unwrap().take();
|
||||
let prev_id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = prev_sink { s.stop(); }
|
||||
if let Some(prev) = prev_id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id: prev,
|
||||
reason: "interrupted",
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// Pause main if and only if we don't already hold a "main was playing"
|
||||
// marker from a superseded preview. swap_or-style: only pause if the flag
|
||||
// is currently false.
|
||||
if !state.preview_main_resume.load(Ordering::Acquire) {
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
let bytes = audio_http_client(&state)
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = preview_format_hint_from_url(&url);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Minimal pipeline: f32 conversion + take_duration cap. No EQ, no crossfade,
|
||||
// no ReplayGain — preview is intentionally simple. Volume is set on the Sink.
|
||||
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
|
||||
let source = decoder
|
||||
.convert_samples::<f32>()
|
||||
.take_duration(dur);
|
||||
|
||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||
let sink = Arc::new(
|
||||
Sink::try_new(&*state.stream_handle.lock().unwrap())
|
||||
.map_err(|e| format!("preview: sink new: {e}"))?,
|
||||
);
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(source);
|
||||
|
||||
// ── Best-effort seek to mid-track start position ─────────────────────────
|
||||
// Symphonia FLAC without SEEKTABLE may fail here; preview then plays from 0
|
||||
// which is acceptable. Hard timeout 800 ms via a worker thread.
|
||||
if start_sec > 0.5 {
|
||||
let start_dur = Duration::from_secs_f64(start_sec);
|
||||
let seek_sink = sink.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
seek_sink.try_seek(start_dur).ok();
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let _ = rx.recv_timeout(Duration::from_millis(800));
|
||||
}
|
||||
|
||||
*state.preview_sink.lock().unwrap() = Some(sink.clone());
|
||||
*state.preview_song_id.lock().unwrap() = Some(id.clone());
|
||||
|
||||
app.emit("audio:preview-start", id.clone()).ok();
|
||||
|
||||
// ── Spawn watchdog: progress emits + auto-end ────────────────────────────
|
||||
let preview_gen_arc = state.preview_gen.clone();
|
||||
let preview_sink_arc = state.preview_sink.clone();
|
||||
let preview_song_arc = state.preview_song_id.clone();
|
||||
let preview_resume_arc = state.preview_main_resume.clone();
|
||||
let main_current = state.current.clone();
|
||||
let app_for_task = app.clone();
|
||||
let id_for_task = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let started = Instant::now();
|
||||
let mut last_emit = Instant::now() - Duration::from_millis(300);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
// Cancel: another preview started or audio_preview_stop bumped the gen.
|
||||
if preview_gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
let dur_secs = dur.as_secs_f64();
|
||||
|
||||
if last_emit.elapsed() >= Duration::from_millis(250) {
|
||||
last_emit = Instant::now();
|
||||
app_for_task.emit("audio:preview-progress", PreviewProgressPayload {
|
||||
id: id_for_task.clone(),
|
||||
elapsed: elapsed.min(dur_secs),
|
||||
duration: dur_secs,
|
||||
}).ok();
|
||||
}
|
||||
|
||||
// Natural end: timer expired OR sink drained early (decode error,
|
||||
// short track, etc.).
|
||||
let drained = match preview_sink_arc.lock().unwrap().as_ref() {
|
||||
Some(s) => s.empty(),
|
||||
None => true,
|
||||
};
|
||||
if elapsed >= dur_secs || drained {
|
||||
// Re-check generation under the cleanup lock to avoid racing
|
||||
// a fresh preview that bumped the counter.
|
||||
if preview_gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
if let Some(s) = preview_sink_arc.lock().unwrap().take() { s.stop(); }
|
||||
let cleared_id = preview_song_arc.lock().unwrap().take()
|
||||
.unwrap_or_else(|| id_for_task.clone());
|
||||
|
||||
// Resume main if we paused it.
|
||||
if preview_resume_arc.swap(false, Ordering::AcqRel) {
|
||||
let mut cur = main_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app_for_task.emit("audio:preview-end", PreviewEndPayload {
|
||||
id: cleared_id,
|
||||
reason: "natural",
|
||||
}).ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
state.preview_gen.fetch_add(1, Ordering::SeqCst);
|
||||
let sink = state.preview_sink.lock().unwrap().take();
|
||||
let id = state.preview_song_id.lock().unwrap().take();
|
||||
if let Some(s) = sink { s.stop(); }
|
||||
|
||||
preview_resume_main(&state);
|
||||
|
||||
if let Some(id) = id {
|
||||
app.emit("audio:preview-end", PreviewEndPayload {
|
||||
id,
|
||||
reason: "user",
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4597,6 +4597,8 @@ pub fn run() {
|
||||
audio::autoeq_fetch_profile,
|
||||
audio::audio_preload,
|
||||
audio::audio_play_radio,
|
||||
audio::audio_preview_play,
|
||||
audio::audio_preview_stop,
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_set_normalization,
|
||||
|
||||
+91
-12
@@ -107,6 +107,7 @@ import { useEqStore } from './store/eqStore';
|
||||
import { useKeybindingsStore, matchInAppBinding, buildInAppBinding } from './store/keybindingsStore';
|
||||
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
|
||||
import { useZipDownloadStore } from './store/zipDownloadStore';
|
||||
import { usePreviewStore } from './store/previewStore';
|
||||
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
|
||||
import PasteClipboardHandler from './components/PasteClipboardHandler';
|
||||
|
||||
@@ -563,6 +564,22 @@ function TauriEventBridge() {
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
// Track-preview lifecycle: Rust audio engine emits start/progress/end. The
|
||||
// store mirrors them so any tracklist row can render its preview UI.
|
||||
useEffect(() => {
|
||||
const unlistenFns: Array<() => void> = [];
|
||||
listen<string>('audio:preview-start', e => {
|
||||
usePreviewStore.getState()._onStart(e.payload);
|
||||
}).then(u => unlistenFns.push(u));
|
||||
listen<{ id: string; elapsed: number; duration: number }>('audio:preview-progress', e => {
|
||||
usePreviewStore.getState()._onProgress(e.payload.id, e.payload.elapsed, e.payload.duration);
|
||||
}).then(u => unlistenFns.push(u));
|
||||
listen<{ id: string; reason: string }>('audio:preview-end', e => {
|
||||
usePreviewStore.getState()._onEnd(e.payload.id);
|
||||
}).then(u => unlistenFns.push(u));
|
||||
return () => { unlistenFns.forEach(fn => fn()); };
|
||||
}, []);
|
||||
|
||||
// Audio output device changed (Bluetooth headphones, USB DAC, etc.)
|
||||
// The Rust device-watcher has already reopened the stream on the new device
|
||||
// and dropped the old Sink, so we just need to restart playback.
|
||||
@@ -884,10 +901,24 @@ function TauriEventBridge() {
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
|
||||
// While a track preview is running, Spacebar pauses the preview rather
|
||||
// than the main player (which is already paused under it). Skip / prev
|
||||
// also cancel the preview so the main player resumes cleanly.
|
||||
const previewing = usePreviewStore.getState().previewingId !== null;
|
||||
|
||||
switch (action) {
|
||||
case 'play-pause': togglePlay(); break;
|
||||
case 'next': next(); break;
|
||||
case 'prev': previous(); break;
|
||||
case 'play-pause':
|
||||
if (previewing) usePreviewStore.getState().stopPreview();
|
||||
else togglePlay();
|
||||
break;
|
||||
case 'next':
|
||||
if (previewing) usePreviewStore.getState().stopPreview();
|
||||
next();
|
||||
break;
|
||||
case 'prev':
|
||||
if (previewing) usePreviewStore.getState().stopPreview();
|
||||
previous();
|
||||
break;
|
||||
case 'volume-up': setVolume(Math.min(1, usePlayerStore.getState().volume + 0.05)); break;
|
||||
case 'volume-down': setVolume(Math.max(0, usePlayerStore.getState().volume - 0.05)); break;
|
||||
case 'seek-forward': {
|
||||
@@ -928,16 +959,36 @@ function TauriEventBridge() {
|
||||
const unlisten: Array<() => void> = [];
|
||||
|
||||
const setup = async () => {
|
||||
// Hardware mediakeys are silently dropped while a preview is playing —
|
||||
// matches the cucadmuh-flow expectation that headphone buttons don't
|
||||
// accidentally interrupt or switch the previewed track.
|
||||
const ifNoPreview = (fn: () => void) => () => {
|
||||
if (usePreviewStore.getState().previewingId === null) fn();
|
||||
};
|
||||
const handlers: Array<[string, () => void]> = [
|
||||
['media:play-pause', () => togglePlay()],
|
||||
['media:play', () => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); }],
|
||||
['media:pause', () => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); }],
|
||||
['media:next', () => next()],
|
||||
['media:prev', () => previous()],
|
||||
['tray:play-pause', () => togglePlay()],
|
||||
['tray:next', () => next()],
|
||||
['tray:previous', () => previous()],
|
||||
['media:stop', () => usePlayerStore.getState().stop()],
|
||||
['media:play-pause', ifNoPreview(() => togglePlay())],
|
||||
['media:play', ifNoPreview(() => { const s = usePlayerStore.getState(); if (!s.isPlaying) s.resume(); })],
|
||||
['media:pause', ifNoPreview(() => { const s = usePlayerStore.getState(); if (s.isPlaying) s.pause(); })],
|
||||
['media:next', ifNoPreview(() => next())],
|
||||
['media:prev', ifNoPreview(() => previous())],
|
||||
// Tray clicks are user-driven UI, so they fall through to the keyboard
|
||||
// semantics: cancel the preview, then act.
|
||||
['tray:play-pause', () => {
|
||||
if (usePreviewStore.getState().previewingId !== null) {
|
||||
usePreviewStore.getState().stopPreview();
|
||||
} else {
|
||||
togglePlay();
|
||||
}
|
||||
}],
|
||||
['tray:next', () => {
|
||||
if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview();
|
||||
next();
|
||||
}],
|
||||
['tray:previous', () => {
|
||||
if (usePreviewStore.getState().previewingId !== null) usePreviewStore.getState().stopPreview();
|
||||
previous();
|
||||
}],
|
||||
['media:stop', ifNoPreview(() => usePlayerStore.getState().stop())],
|
||||
['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }],
|
||||
['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }],
|
||||
];
|
||||
@@ -1101,6 +1152,34 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
// Hide all inline track-preview buttons when the user opts out — single
|
||||
// CSS hook (`html[data-track-previews="off"]`) instead of conditional
|
||||
// rendering in every tracklist. Per-location toggles use additional
|
||||
// attributes `data-track-previews-{location}` consumed by scoped selectors.
|
||||
const trackPreviewsEnabled = useAuthStore(s => s.trackPreviewsEnabled);
|
||||
const trackPreviewLocations = useAuthStore(s => s.trackPreviewLocations);
|
||||
const trackPreviewDurationSec = useAuthStore(s => s.trackPreviewDurationSec);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-track-previews',
|
||||
trackPreviewsEnabled ? 'on' : 'off',
|
||||
);
|
||||
}, [trackPreviewsEnabled]);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
(Object.keys(trackPreviewLocations) as Array<keyof typeof trackPreviewLocations>).forEach(loc => {
|
||||
root.setAttribute(`data-track-previews-${loc.toLowerCase()}`, trackPreviewLocations[loc] ? 'on' : 'off');
|
||||
});
|
||||
}, [trackPreviewLocations]);
|
||||
// Drive the SVG progress-ring keyframe duration from the same setting that
|
||||
// governs the engine's auto-stop timer so both finish in lockstep.
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--preview-duration',
|
||||
`${trackPreviewDurationSec}s`,
|
||||
);
|
||||
}, [trackPreviewDurationSec]);
|
||||
|
||||
// Main window only: push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
if (isMiniWindow) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Heart, ListPlus, X, ChevronDown, Check, RotateCcw } from 'lucide-react';
|
||||
import { Play, ChevronRight, Heart, ListPlus, X, ChevronDown, Check, RotateCcw, Square } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -11,6 +11,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import StarRating from './StarRating';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
@@ -117,6 +118,8 @@ const TrackRow = React.memo(function TrackRow({
|
||||
// Fine-grained: only re-renders when THIS row's selection boolean flips.
|
||||
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
|
||||
const isActive = currentTrackId === song.id;
|
||||
// Primitive selector: row only re-renders when *this song's* preview state flips.
|
||||
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
|
||||
|
||||
const renderCell = (colDef: ColDef) => {
|
||||
const key = colDef.key as ColKey;
|
||||
@@ -125,26 +128,51 @@ const TrackRow = React.memo(function TrackRow({
|
||||
return (
|
||||
<div
|
||||
key="num"
|
||||
className={`track-num${isActive ? ' track-num-active' : ''}${isActive && !isPlaying ? ' track-num-paused' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
className={`track-num${isActive ? ' track-num-active' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
|
||||
/>
|
||||
{isActive && isPlaying && (
|
||||
{isActive && isPlaying ? (
|
||||
<span className="track-num-eq">
|
||||
<div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="track-num-number">{song.track ?? '—'}</span>
|
||||
)}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{song.track ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title':
|
||||
return (
|
||||
<div key="title" className="track-info">
|
||||
<div key="title" className="track-info track-info-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'albums');
|
||||
}}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{isPreviewing
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -216,7 +244,7 @@ const TrackRow = React.memo(function TrackRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`track-row track-row-va${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
className={`track-row track-row-va track-row-with-actions${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
@@ -585,6 +613,7 @@ export default function AlbumTrackList({
|
||||
<div
|
||||
className="tracklist"
|
||||
ref={tracklistRef}
|
||||
data-preview-loc="albums"
|
||||
onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
suggestOrbitTrack,
|
||||
@@ -1515,7 +1515,7 @@ export default function ContextMenu() {
|
||||
newQueue.splice(currentIdx + 1, 0, song);
|
||||
usePlayerStore.setState({ queue: newQueue });
|
||||
})}>
|
||||
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
@@ -1679,7 +1679,7 @@ export default function ContextMenu() {
|
||||
newQueue.splice(currentIdx + 1, 0, song);
|
||||
usePlayerStore.setState({ queue: newQueue });
|
||||
})}>
|
||||
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||
@@ -1815,7 +1815,7 @@ export default function ContextMenu() {
|
||||
const currentIdx = usePlayerStore.getState().queueIndex;
|
||||
usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1);
|
||||
})}>
|
||||
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
|
||||
@@ -870,6 +870,22 @@ export const deTranslation = {
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
trackPreviewsTitle: 'Track-Vorschau',
|
||||
trackPreviewsToggle: 'Track-Vorschau aktivieren',
|
||||
trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.',
|
||||
trackPreviewStart: 'Startposition',
|
||||
trackPreviewStartDesc: 'Wie weit der Track schon gespielt sein soll wenn die Vorschau startet (% der Länge).',
|
||||
trackPreviewDuration: 'Dauer',
|
||||
trackPreviewDurationDesc: 'Wie lange jede Vorschau spielt bevor sie automatisch stoppt.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Wo Vorschauen erscheinen',
|
||||
trackPreviewLocationsDesc: 'Wähle aus, in welchen Listen die Vorschau-Buttons angezeigt werden.',
|
||||
trackPreviewLocation_suggestions: 'In Playlist-Vorschlägen',
|
||||
trackPreviewLocation_albums: 'In Album-Tracklisten',
|
||||
trackPreviewLocation_playlists: 'In Playlists',
|
||||
trackPreviewLocation_favorites: 'In Favoriten',
|
||||
trackPreviewLocation_artist: 'In Künstler-Top-Tracks',
|
||||
trackPreviewLocation_randomMix: 'In Random Mix',
|
||||
preloadMode: 'Nächsten Track vorpuffern',
|
||||
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
|
||||
nextTrackBufferingTitle: 'Pufferung',
|
||||
|
||||
@@ -876,6 +876,22 @@ export const enTranslation = {
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
trackPreviewsTitle: 'Track Previews',
|
||||
trackPreviewsToggle: 'Enable track previews',
|
||||
trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.',
|
||||
trackPreviewStart: 'Start position',
|
||||
trackPreviewStartDesc: 'How far into the track the preview starts (% of track length).',
|
||||
trackPreviewDuration: 'Duration',
|
||||
trackPreviewDurationDesc: 'How long each preview plays before it stops.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Where previews appear',
|
||||
trackPreviewLocationsDesc: 'Pick which lists show inline preview buttons.',
|
||||
trackPreviewLocation_suggestions: 'In playlist suggestions',
|
||||
trackPreviewLocation_albums: 'In album tracklists',
|
||||
trackPreviewLocation_playlists: 'In playlists',
|
||||
trackPreviewLocation_favorites: 'In favorites',
|
||||
trackPreviewLocation_artist: 'In artist top tracks',
|
||||
trackPreviewLocation_randomMix: 'In Random Mix',
|
||||
preloadMode: 'Preload Next Track',
|
||||
preloadModeDesc: 'When to start buffering the next track in the queue',
|
||||
nextTrackBufferingTitle: 'Buffering',
|
||||
|
||||
@@ -863,6 +863,22 @@ export const esTranslation = {
|
||||
notWithCrossfade: 'No disponible mientras Crossfade está activo',
|
||||
gapless: 'Reproducción Gapless',
|
||||
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
|
||||
trackPreviewsTitle: 'Previsualizaciones de pistas',
|
||||
trackPreviewsToggle: 'Activar previsualizaciones',
|
||||
trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.',
|
||||
trackPreviewStart: 'Posición inicial',
|
||||
trackPreviewStartDesc: 'Cuánto avanza la pista antes de iniciar la previsualización (% de la duración).',
|
||||
trackPreviewDuration: 'Duración',
|
||||
trackPreviewDurationDesc: 'Cuánto dura cada previsualización antes de detenerse.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Dónde aparecen las previsualizaciones',
|
||||
trackPreviewLocationsDesc: 'Elige en qué listas se muestran los botones de previsualización.',
|
||||
trackPreviewLocation_suggestions: 'En sugerencias de listas',
|
||||
trackPreviewLocation_albums: 'En listas de pistas de álbum',
|
||||
trackPreviewLocation_playlists: 'En listas de reproducción',
|
||||
trackPreviewLocation_favorites: 'En favoritos',
|
||||
trackPreviewLocation_artist: 'En las mejores pistas del artista',
|
||||
trackPreviewLocation_randomMix: 'En Random Mix',
|
||||
preloadMode: 'Precargar Siguiente Pista',
|
||||
preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola',
|
||||
nextTrackBufferingTitle: 'Buffer',
|
||||
|
||||
@@ -858,6 +858,22 @@ export const frTranslation = {
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
trackPreviewsTitle: 'Aperçus de pistes',
|
||||
trackPreviewsToggle: 'Activer les aperçus de pistes',
|
||||
trackPreviewsDesc: 'Affiche les boutons Lecture et Aperçu dans les listes pour un court extrait au milieu du morceau.',
|
||||
trackPreviewStart: 'Position de départ',
|
||||
trackPreviewStartDesc: 'À quel point du morceau l\'aperçu commence (% de la durée).',
|
||||
trackPreviewDuration: 'Durée',
|
||||
trackPreviewDurationDesc: 'Combien de temps chaque aperçu joue avant de s\'arrêter.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Où afficher les aperçus',
|
||||
trackPreviewLocationsDesc: 'Choisissez dans quelles listes afficher les boutons d\'aperçu.',
|
||||
trackPreviewLocation_suggestions: 'Dans les suggestions de playlist',
|
||||
trackPreviewLocation_albums: 'Dans les listes d\'albums',
|
||||
trackPreviewLocation_playlists: 'Dans les playlists',
|
||||
trackPreviewLocation_favorites: 'Dans les favoris',
|
||||
trackPreviewLocation_artist: 'Dans les meilleurs morceaux de l\'artiste',
|
||||
trackPreviewLocation_randomMix: 'Dans Random Mix',
|
||||
preloadMode: 'Précharger la piste suivante',
|
||||
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
|
||||
nextTrackBufferingTitle: 'Mise en mémoire tampon',
|
||||
|
||||
@@ -857,6 +857,22 @@ export const nbTranslation = {
|
||||
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
|
||||
gapless: 'Gapless avspilling',
|
||||
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
|
||||
trackPreviewsTitle: 'Sporforhåndsvisning',
|
||||
trackPreviewsToggle: 'Aktiver sporforhåndsvisning',
|
||||
trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.',
|
||||
trackPreviewStart: 'Startposisjon',
|
||||
trackPreviewStartDesc: 'Hvor langt inn i sporet forhåndsvisningen starter (% av lengden).',
|
||||
trackPreviewDuration: 'Varighet',
|
||||
trackPreviewDurationDesc: 'Hvor lenge hver forhåndsvisning spiller før den stopper.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Hvor forhåndsvisninger vises',
|
||||
trackPreviewLocationsDesc: 'Velg hvilke lister som viser forhåndsvisningsknapper.',
|
||||
trackPreviewLocation_suggestions: 'I spillelisteforslag',
|
||||
trackPreviewLocation_albums: 'I albumsporlister',
|
||||
trackPreviewLocation_playlists: 'I spillelister',
|
||||
trackPreviewLocation_favorites: 'I favoritter',
|
||||
trackPreviewLocation_artist: 'I artistens toppspor',
|
||||
trackPreviewLocation_randomMix: 'I Random Mix',
|
||||
infiniteQueue: 'Uendelig kø',
|
||||
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
|
||||
experimental: 'Eksperimentell',
|
||||
|
||||
@@ -857,6 +857,22 @@ export const nlTranslation = {
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
trackPreviewsTitle: 'Track-voorvertoning',
|
||||
trackPreviewsToggle: 'Track-voorvertoning inschakelen',
|
||||
trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.',
|
||||
trackPreviewStart: 'Startpositie',
|
||||
trackPreviewStartDesc: 'Hoever in de track de voorvertoning start (% van de duur).',
|
||||
trackPreviewDuration: 'Duur',
|
||||
trackPreviewDurationDesc: 'Hoe lang elke voorvertoning speelt voordat hij stopt.',
|
||||
trackPreviewDurationSecs: '{{n}} s',
|
||||
trackPreviewLocationsTitle: 'Waar voorvertoningen verschijnen',
|
||||
trackPreviewLocationsDesc: 'Kies in welke lijsten de voorvertoning-knoppen worden weergegeven.',
|
||||
trackPreviewLocation_suggestions: 'In playlist-suggesties',
|
||||
trackPreviewLocation_albums: 'In albumlijsten',
|
||||
trackPreviewLocation_playlists: 'In playlists',
|
||||
trackPreviewLocation_favorites: 'In favorieten',
|
||||
trackPreviewLocation_artist: 'In top-tracks van artiest',
|
||||
trackPreviewLocation_randomMix: 'In Random Mix',
|
||||
preloadMode: 'Volgend nummer vooraf laden',
|
||||
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
|
||||
nextTrackBufferingTitle: 'Buffering',
|
||||
|
||||
@@ -909,6 +909,22 @@ export const ruTranslation = {
|
||||
notWithCrossfade: 'Недоступно при включённом кроссфейде',
|
||||
gapless: 'Без пауз между треками',
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
trackPreviewsTitle: 'Превью треков',
|
||||
trackPreviewsToggle: 'Включить превью треков',
|
||||
trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.',
|
||||
trackPreviewStart: 'Начальная позиция',
|
||||
trackPreviewStartDesc: 'С какого момента трека начинается превью (% от длительности).',
|
||||
trackPreviewDuration: 'Длительность',
|
||||
trackPreviewDurationDesc: 'Сколько играет каждое превью до автоматической остановки.',
|
||||
trackPreviewDurationSecs: '{{n}} с',
|
||||
trackPreviewLocationsTitle: 'Где показывать превью',
|
||||
trackPreviewLocationsDesc: 'Выберите, в каких списках показывать кнопки превью.',
|
||||
trackPreviewLocation_suggestions: 'В предложениях плейлиста',
|
||||
trackPreviewLocation_albums: 'В списках треков альбома',
|
||||
trackPreviewLocation_playlists: 'В плейлистах',
|
||||
trackPreviewLocation_favorites: 'В избранном',
|
||||
trackPreviewLocation_artist: 'В топ-треках исполнителя',
|
||||
trackPreviewLocation_randomMix: 'В Random Mix',
|
||||
preloadMode: 'Предзагрузка следующего трека',
|
||||
preloadModeDesc: 'Когда начинать буферизацию следующего в очереди',
|
||||
nextTrackBufferingTitle: 'Буферизация',
|
||||
|
||||
@@ -852,6 +852,22 @@ export const zhTranslation = {
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
trackPreviewsTitle: '曲目预览',
|
||||
trackPreviewsToggle: '启用曲目预览',
|
||||
trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。',
|
||||
trackPreviewStart: '起始位置',
|
||||
trackPreviewStartDesc: '预览从曲目何处开始(占总时长的百分比)。',
|
||||
trackPreviewDuration: '时长',
|
||||
trackPreviewDurationDesc: '每段预览自动停止前的播放时长。',
|
||||
trackPreviewDurationSecs: '{{n}} 秒',
|
||||
trackPreviewLocationsTitle: '预览显示位置',
|
||||
trackPreviewLocationsDesc: '选择在哪些列表中显示预览按钮。',
|
||||
trackPreviewLocation_suggestions: '播放列表建议中',
|
||||
trackPreviewLocation_albums: '专辑曲目列表中',
|
||||
trackPreviewLocation_playlists: '播放列表中',
|
||||
trackPreviewLocation_favorites: '收藏中',
|
||||
trackPreviewLocation_artist: '艺人热门曲目中',
|
||||
trackPreviewLocation_randomMix: 'Random Mix 中',
|
||||
preloadMode: '预加载下一曲目',
|
||||
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
|
||||
nextTrackBufferingTitle: '缓冲',
|
||||
|
||||
@@ -4,11 +4,12 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronUp, Share2 } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2 } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -80,6 +81,7 @@ export default function ArtistDetail() {
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const currentTrack = usePlayerStore(state => state.currentTrack);
|
||||
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
@@ -688,7 +690,7 @@ export default function ArtistDetail() {
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('topTracks'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
@@ -700,7 +702,7 @@ export default function ArtistDetail() {
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
className="track-row track-row-with-actions"
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
@@ -716,12 +718,38 @@ export default function ArtistDetail() {
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}>
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-info" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div className="track-info track-info-suggestion" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
{song.coverArt && (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 64)}
|
||||
|
||||
+40
-8
@@ -9,8 +9,9 @@ import {
|
||||
buildCoverArtUrl, coverArtCacheKey,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Square, Star, Users, X, SlidersHorizontal, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { unstar } from '../api/subsonic';
|
||||
@@ -85,6 +86,7 @@ export default function Favorites() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
@@ -472,7 +474,7 @@ export default function Favorites() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
||||
<div className="tracklist" data-preview-loc="favorites" style={{ padding: 0 }} ref={tracklistRef} onClick={e => {
|
||||
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
|
||||
}}>
|
||||
|
||||
@@ -623,7 +625,7 @@ export default function Favorites() {
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
className={`track-row track-row-va track-row-with-actions${currentTrack?.id === song.id ? ' active' : ''}${isSelected ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
@@ -669,14 +671,44 @@ export default function Favorites() {
|
||||
{visibleCols.map(colDef => {
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}>
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
<span className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>
|
||||
) : (
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'title': return (
|
||||
<div key="title" className="track-info track-info-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, visibleSongs.map(songToTrack)); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'favorites'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
|
||||
|
||||
+56
-101
@@ -7,11 +7,12 @@ import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||
import {
|
||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||
search, setRating, star, unstar,
|
||||
getRandomSongs, buildDownloadUrl, buildStreamUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -292,10 +293,7 @@ export default function PlaylistDetail() {
|
||||
const [sortClickCount, setSortClickCount] = useState(0);
|
||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewTimerRef = useRef<number | null>(null);
|
||||
const previewResumeRef = useRef<boolean>(false);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const zipDownloads = useZipDownloadStore(s => s.downloads);
|
||||
@@ -933,97 +931,23 @@ export default function PlaylistDetail() {
|
||||
showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name }));
|
||||
};
|
||||
|
||||
// ── Preview (30s mid-song sample via parallel HTML5 audio) ────
|
||||
// Session counter invalidates `loadedmetadata`/`error` callbacks
|
||||
// bound to a previous preview that the user has already switched
|
||||
// away from — without it, a slow-to-load preview can `play()` on a
|
||||
// discarded element while the new one is still loading.
|
||||
const previewSessionRef = useRef<number>(0);
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
previewSessionRef.current++;
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
if (previewTimerRef.current !== null) {
|
||||
clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = null;
|
||||
}
|
||||
if (previewResumeRef.current) {
|
||||
previewResumeRef.current = false;
|
||||
usePlayerStore.getState().resume();
|
||||
}
|
||||
setPreviewingId(null);
|
||||
// ── Preview (30s mid-song sample via Rust audio engine) ────────
|
||||
// Pause/resume of the main player + timer + cancel-on-supersede are all
|
||||
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
|
||||
// engine events so we just dispatch here and read `previewingId` for UI.
|
||||
const startPreview = useCallback((song: SubsonicSong) => {
|
||||
usePreviewStore.getState().startPreview({
|
||||
id: song.id,
|
||||
duration: song.duration,
|
||||
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
||||
}, []);
|
||||
|
||||
const startPreview = useCallback((song: SubsonicSong) => {
|
||||
if (previewingId === song.id) {
|
||||
stopPreview();
|
||||
return;
|
||||
// Cancel any in-flight preview when the user navigates away.
|
||||
useEffect(() => () => {
|
||||
if (usePreviewStore.getState().previewingId) {
|
||||
usePreviewStore.getState().stopPreview();
|
||||
}
|
||||
// Tear down audio + timer but keep the resume flag so the main
|
||||
// player only resumes after the *last* preview ends.
|
||||
previewSessionRef.current++;
|
||||
if (previewAudioRef.current) {
|
||||
previewAudioRef.current.pause();
|
||||
previewAudioRef.current.src = '';
|
||||
previewAudioRef.current = null;
|
||||
}
|
||||
if (previewTimerRef.current !== null) {
|
||||
clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = null;
|
||||
}
|
||||
if (!previewResumeRef.current && usePlayerStore.getState().isPlaying) {
|
||||
usePlayerStore.getState().pause();
|
||||
previewResumeRef.current = true;
|
||||
}
|
||||
const session = ++previewSessionRef.current;
|
||||
const audio = new Audio();
|
||||
// Match the rough loudness compensation the main Rust player applies,
|
||||
// otherwise unanalysed previews blast out at full natural level
|
||||
// while the main player serves cache-corrected tracks.
|
||||
const baseVolume = usePlayerStore.getState().volume;
|
||||
const auth = useAuthStore.getState();
|
||||
const attenuationDb = auth.normalizationEngine === 'loudness'
|
||||
? Math.min(0, auth.loudnessPreAnalysisAttenuationDb)
|
||||
: 0;
|
||||
audio.volume = Math.max(0, Math.min(1, baseVolume * Math.pow(10, attenuationDb / 20)));
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (previewSessionRef.current !== session) return;
|
||||
const dur = audio.duration && Number.isFinite(audio.duration)
|
||||
? audio.duration
|
||||
: (song.duration ?? 0);
|
||||
audio.currentTime = Math.max(0, dur * 0.33);
|
||||
audio.play().catch(() => {
|
||||
if (previewSessionRef.current === session) stopPreview();
|
||||
});
|
||||
}, { once: true });
|
||||
audio.addEventListener('error', () => {
|
||||
if (previewSessionRef.current === session) stopPreview();
|
||||
}, { once: true });
|
||||
audio.src = buildStreamUrl(song.id);
|
||||
previewAudioRef.current = audio;
|
||||
setPreviewingId(song.id);
|
||||
previewTimerRef.current = window.setTimeout(stopPreview, 30000);
|
||||
}, [previewingId, stopPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.isPlaying && !prev.isPlaying && previewAudioRef.current) {
|
||||
// Main playback resumed externally (spacebar, mediakey, suggestion-row click).
|
||||
// Cancel the preview without resuming again.
|
||||
previewResumeRef.current = false;
|
||||
stopPreview();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
stopPreview();
|
||||
};
|
||||
}, [stopPreview]);
|
||||
}, []);
|
||||
|
||||
// ── Rating / Star ─────────────────────────────────────────────
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
@@ -1518,7 +1442,7 @@ export default function PlaylistDetail() {
|
||||
)}
|
||||
|
||||
{/* ── Tracklist ── */}
|
||||
<div className="tracklist" ref={tracklistRef}>
|
||||
<div className="tracklist" data-preview-loc="playlists" ref={tracklistRef}>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -1729,7 +1653,7 @@ export default function PlaylistDetail() {
|
||||
)}
|
||||
<div
|
||||
data-track-idx={realIdx}
|
||||
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
className={`track-row track-row-va track-row-with-actions tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
|
||||
style={gridStyle}
|
||||
onMouseEnter={e => !isFiltered && handleRowMouseEnter(i, e)}
|
||||
onMouseDown={e => handleRowMouseDown(e, realIdx)}
|
||||
@@ -1760,15 +1684,46 @@ export default function PlaylistDetail() {
|
||||
const inSelectMode = selectedIds.size > 0;
|
||||
switch (colDef.key) {
|
||||
case 'num': return (
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}>
|
||||
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} />
|
||||
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>
|
||||
) : (
|
||||
<span className="track-num-number">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'title': return (
|
||||
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div key="title" className="track-info track-info-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(displayedTracks[i], displayedTracks); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'playlists');
|
||||
}}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
@@ -1816,7 +1771,7 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
|
||||
{/* ── Suggestions ── */}
|
||||
<div className="playlist-suggestions tracklist">
|
||||
<div className="playlist-suggestions tracklist" data-preview-loc="suggestions">
|
||||
<div className="playlist-suggestions-header">
|
||||
<div className="playlist-suggestions-title">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
|
||||
|
||||
+71
-15
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePreviewStore } from '../store/previewStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
|
||||
import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
@@ -38,6 +39,7 @@ export default function RandomMix() {
|
||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -408,7 +410,7 @@ export default function RandomMix() {
|
||||
{genreMixLoading && genreMixSongs.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist" data-preview-loc="randomMix">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
@@ -427,7 +429,7 @@ export default function RandomMix() {
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
className={`track-row track-row-with-actions${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
||||
onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined}
|
||||
@@ -449,12 +451,40 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}>
|
||||
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`}>
|
||||
{isCurrentTrack && isPlaying ? (
|
||||
<span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-info track-info-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'randomMix'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell">
|
||||
{artist ? (
|
||||
<button
|
||||
@@ -498,7 +528,7 @@ export default function RandomMix() {
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist" data-preview-loc="randomMix">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
|
||||
<div></div>
|
||||
<div>{t('randomMix.trackTitle')}</div>
|
||||
@@ -527,7 +557,7 @@ export default function RandomMix() {
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
className={`track-row track-row-with-actions${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
|
||||
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
||||
onDoubleClick={orbitActive ? e => { if ((e.target as HTMLElement).closest('button, a, input')) return; addTrackToOrbit(song.id); } : undefined}
|
||||
@@ -553,13 +583,39 @@ export default function RandomMix() {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}>
|
||||
{isCurrentTrack && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
|
||||
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
<div className={`track-num${isCurrentTrack ? ' track-num-active' : ''}`}>
|
||||
{isCurrentTrack && isPlaying ? (
|
||||
<span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<div className="track-info track-info-suggestion">
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTrack(track, queueSongs); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'randomMix'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,11 +31,13 @@ import {
|
||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
ServerProfile,
|
||||
MIX_MIN_RATING_FILTER_MAX_STARS,
|
||||
TRACK_PREVIEW_LOCATIONS,
|
||||
type SeekbarStyle,
|
||||
type LyricsSourceId,
|
||||
type LyricsSourceConfig,
|
||||
type LoggingMode,
|
||||
type LoudnessLufsPreset,
|
||||
type TrackPreviewLocation,
|
||||
} from '../store/authStore';
|
||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
@@ -2523,6 +2525,111 @@ export default function Settings() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.trackPreviewsTitle')}
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t('settings.trackPreviewsToggle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.trackPreviewsDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.trackPreviewsToggle')}>
|
||||
<input type="checkbox" checked={auth.trackPreviewsEnabled}
|
||||
onChange={e => auth.setTrackPreviewsEnabled(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
|
||||
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{t(`settings.trackPreviewLocation_${loc}`)}
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
|
||||
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
|
||||
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,6 +49,34 @@ function sanitizeLoudnessPreAnalysisFromStorage(v: unknown): number {
|
||||
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||
|
||||
export type TrackPreviewLocation =
|
||||
| 'suggestions'
|
||||
| 'albums'
|
||||
| 'playlists'
|
||||
| 'favorites'
|
||||
| 'artist'
|
||||
| 'randomMix';
|
||||
|
||||
export type TrackPreviewLocations = Record<TrackPreviewLocation, boolean>;
|
||||
|
||||
export const TRACK_PREVIEW_LOCATIONS: readonly TrackPreviewLocation[] = [
|
||||
'suggestions',
|
||||
'albums',
|
||||
'playlists',
|
||||
'favorites',
|
||||
'artist',
|
||||
'randomMix',
|
||||
];
|
||||
|
||||
const DEFAULT_TRACK_PREVIEW_LOCATIONS: TrackPreviewLocations = {
|
||||
suggestions: true,
|
||||
albums: true,
|
||||
playlists: true,
|
||||
favorites: true,
|
||||
artist: true,
|
||||
randomMix: true,
|
||||
};
|
||||
|
||||
const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [
|
||||
{ id: 'server', enabled: true },
|
||||
{ id: 'lrclib', enabled: true },
|
||||
@@ -89,6 +117,14 @@ interface AuthState {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
gaplessEnabled: boolean;
|
||||
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
|
||||
trackPreviewsEnabled: boolean;
|
||||
/** Per-location toggles. Only honoured when `trackPreviewsEnabled` is true. */
|
||||
trackPreviewLocations: TrackPreviewLocations;
|
||||
/** Mid-track start position as a 0…1 ratio. Default 0.33 = 33%. */
|
||||
trackPreviewStartRatio: number;
|
||||
/** Preview window length in seconds. Default 30 s. */
|
||||
trackPreviewDurationSec: number;
|
||||
preloadMode: 'off' | 'balanced' | 'early' | 'custom';
|
||||
preloadCustomSeconds: number;
|
||||
infiniteQueueEnabled: boolean;
|
||||
@@ -252,6 +288,10 @@ interface AuthState {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
setGaplessEnabled: (v: boolean) => void;
|
||||
setTrackPreviewsEnabled: (v: boolean) => void;
|
||||
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
|
||||
setTrackPreviewStartRatio: (v: number) => void;
|
||||
setTrackPreviewDurationSec: (v: number) => void;
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void;
|
||||
setPreloadCustomSeconds: (v: number) => void;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
@@ -367,6 +407,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
gaplessEnabled: false,
|
||||
trackPreviewsEnabled: true,
|
||||
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
|
||||
trackPreviewStartRatio: 0.33,
|
||||
trackPreviewDurationSec: 30,
|
||||
preloadMode: 'balanced',
|
||||
preloadCustomSeconds: 30,
|
||||
infiniteQueueEnabled: false,
|
||||
@@ -521,6 +565,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
|
||||
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setTrackPreviewsEnabled: (v) => set({ trackPreviewsEnabled: !!v }),
|
||||
setTrackPreviewLocation: (location, enabled) => set(state => ({
|
||||
trackPreviewLocations: { ...state.trackPreviewLocations, [location]: !!enabled },
|
||||
})),
|
||||
setTrackPreviewStartRatio: (v) => set({ trackPreviewStartRatio: Math.max(0, Math.min(0.9, v)) }),
|
||||
setTrackPreviewDurationSec: (v) => set({ trackPreviewDurationSec: Math.max(5, Math.min(120, Math.round(v))) }),
|
||||
setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
|
||||
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
|
||||
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
||||
|
||||
interface PreviewState {
|
||||
/** Subsonic song id of the active preview, or null when nothing previews. */
|
||||
previewingId: string | null;
|
||||
/** Seconds elapsed in the current preview window. */
|
||||
elapsed: number;
|
||||
/** Total preview window in seconds (echoes the duration_sec arg). */
|
||||
duration: number;
|
||||
|
||||
startPreview: (song: { id: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
||||
stopPreview: () => Promise<void>;
|
||||
|
||||
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
||||
_onStart: (id: string) => void;
|
||||
/** Internal — called from the TauriEventBridge on `audio:preview-progress`. */
|
||||
_onProgress: (id: string, elapsed: number, duration: number) => void;
|
||||
/** Internal — called from the TauriEventBridge on `audio:preview-end`. */
|
||||
_onEnd: (id: string) => void;
|
||||
}
|
||||
|
||||
const PREVIEW_VOLUME_MATCH = true;
|
||||
|
||||
export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||
previewingId: null,
|
||||
elapsed: 0,
|
||||
duration: 30,
|
||||
|
||||
startPreview: async (song, location) => {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.trackPreviewsEnabled) return;
|
||||
if (!auth.trackPreviewLocations[location]) return;
|
||||
|
||||
const current = get().previewingId;
|
||||
if (current === song.id) {
|
||||
await get().stopPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const previewDuration = auth.trackPreviewDurationSec;
|
||||
const startRatio = auth.trackPreviewStartRatio;
|
||||
const url = buildStreamUrl(song.id);
|
||||
const trackDuration = Math.max(song.duration ?? 0, 0);
|
||||
const startSec = trackDuration > previewDuration * 1.5
|
||||
? trackDuration * startRatio
|
||||
: 0;
|
||||
|
||||
// Match the main player's effective volume so preview doesn't blast at
|
||||
// unattenuated level. LUFS pre-analysis attenuation is folded into base
|
||||
// volume by the audio engine for the main sink; we mirror by reading the
|
||||
// player volume + applying the same headroom multiplier conceptually.
|
||||
let volume = usePlayerStore.getState().volume;
|
||||
if (PREVIEW_VOLUME_MATCH) {
|
||||
if (auth.normalizationEngine === 'loudness') {
|
||||
const preDbAtt = Math.min(0, auth.loudnessPreAnalysisAttenuationDb ?? -4.5);
|
||||
volume = volume * Math.pow(10, preDbAtt / 20);
|
||||
}
|
||||
}
|
||||
|
||||
set({ previewingId: song.id, elapsed: 0, duration: previewDuration });
|
||||
|
||||
try {
|
||||
await invoke('audio_preview_play', {
|
||||
id: song.id,
|
||||
url,
|
||||
startSec,
|
||||
durationSec: previewDuration,
|
||||
volume: Math.max(0, Math.min(1, volume)),
|
||||
});
|
||||
} catch (e) {
|
||||
// Roll back optimistic state on failure.
|
||||
if (get().previewingId === song.id) {
|
||||
set({ previewingId: null, elapsed: 0 });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
stopPreview: async () => {
|
||||
if (!get().previewingId) return;
|
||||
try {
|
||||
await invoke('audio_preview_stop');
|
||||
} catch {
|
||||
/* engine will emit preview-end anyway; clear locally as fallback */
|
||||
set({ previewingId: null, elapsed: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
_onStart: (id) => {
|
||||
if (get().previewingId !== id) {
|
||||
set({ previewingId: id, elapsed: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
_onProgress: (id, elapsed, duration) => {
|
||||
if (get().previewingId !== id) return;
|
||||
set({ elapsed, duration });
|
||||
},
|
||||
|
||||
_onEnd: (id) => {
|
||||
if (get().previewingId !== id) return;
|
||||
set({ previewingId: null, elapsed: 0 });
|
||||
},
|
||||
}));
|
||||
@@ -1765,7 +1765,28 @@
|
||||
}
|
||||
|
||||
.playlist-suggestion-preview-btn.is-previewing .playlist-suggestion-preview-ring-progress {
|
||||
animation: playlist-preview-progress 30s linear forwards;
|
||||
animation: playlist-preview-progress var(--preview-duration, 30s) linear forwards;
|
||||
}
|
||||
|
||||
/* Settings opt-out: a single root attribute hides every inline preview button
|
||||
without forcing every tracklist to plumb the flag through props. Master
|
||||
wins — when off, every preview button is hidden regardless of per-location
|
||||
state. */
|
||||
html[data-track-previews="off"] .playlist-suggestion-preview-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Per-location hide: each tracklist container marks its scope via
|
||||
`data-preview-loc="<id>"`. When the matching root attribute is "off", only
|
||||
buttons inside that scope disappear. Attribute names are stored lowercase
|
||||
by the DOM so `randomMix` becomes `randommix`. */
|
||||
html[data-track-previews-suggestions="off"] [data-preview-loc="suggestions"] .playlist-suggestion-preview-btn,
|
||||
html[data-track-previews-albums="off"] [data-preview-loc="albums"] .playlist-suggestion-preview-btn,
|
||||
html[data-track-previews-playlists="off"] [data-preview-loc="playlists"] .playlist-suggestion-preview-btn,
|
||||
html[data-track-previews-favorites="off"] [data-preview-loc="favorites"] .playlist-suggestion-preview-btn,
|
||||
html[data-track-previews-artist="off"] [data-preview-loc="artist"] .playlist-suggestion-preview-btn,
|
||||
html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .playlist-suggestion-preview-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes playlist-preview-progress {
|
||||
@@ -1865,6 +1886,25 @@
|
||||
.track-num.track-num-paused .track-num-play { display: flex; opacity: 0.5; }
|
||||
.track-row:hover .track-num.track-num-paused .track-num-play { opacity: 1; }
|
||||
|
||||
/* ── Tracklists with inline action buttons (AlbumTrackList & co.) ──────────
|
||||
Track number stays static instead of swapping to a play icon on hover.
|
||||
The dedicated Play + Preview buttons in the title cell take over both
|
||||
click-to-play and click-to-preview. Active+playing rows still show the
|
||||
eq-bars, active+paused rows fall back to the static number.
|
||||
These rules MUST come after the legacy `.track-row:hover` swap rules so
|
||||
the cascade overrides them. */
|
||||
.track-row-with-actions:hover .track-num-number { display: inline; }
|
||||
.track-row-with-actions .track-num { cursor: default; }
|
||||
.track-row-with-actions .track-num.track-num-active .track-num-number { display: inline; }
|
||||
.track-row-with-actions:hover .track-num.track-num-active .track-num-eq { display: flex; }
|
||||
|
||||
/* Mirror the suggestion preview-button hover-highlight here so the icon
|
||||
reaches full opacity / accent colour when the row is hovered. */
|
||||
.track-row-with-actions:hover .playlist-suggestion-preview-btn {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Equalizer bars — shown for the currently playing track */
|
||||
.eq-bars {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user