From 20a083a9a626db62a9c4261d3b648f9ab2d47e3c Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 1 May 2026 12:57:34 +0200 Subject: [PATCH] feat(player): preview indicator in player bar + smart stop semantics (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(player): preview-active state on play button (ring + stop icon) Checkpoint: play button mirrors the inline preview button from tracklists during preview playback — hollow circle, accent ring depleting over the preview duration, Square (stop) icon. Click still resumes main playback, which the Rust audio engine cancels the preview for. i18n key player.previewActive in all 8 locales for tooltip + aria-label. * feat(player): show preview track in player bar + smart stop semantics The player-bar info cell (cover, title, artist) now mirrors the previewing track during preview playback, with a small accent "Preview" pill above the title and an accent top-border on the bar. Rating, fullscreen hint and album/artist link clicks are suppressed while previewing — they target the queued track, not the preview. Stop semantics for the two transport buttons during preview: - Big play button (Square+ring visual): stops preview, main auto-resumes if it was playing before. Matches the tracklist preview-button behaviour. - Small Stop button: new audio_preview_stop_silent Rust command — stops preview AND leaves main paused, so "Stop = silence" actually goes silent. previewStore now stores the full PreviewingTrack (id, title, artist, coverArt) — the seven startPreview call sites pass it through. i18n key player.previewLabel in all 8 locales. --- src-tauri/src/audio.rs | 20 +++++++- src-tauri/src/lib.rs | 1 + src/components/AlbumTrackList.tsx | 2 +- src/components/PlayerBar.tsx | 84 +++++++++++++++++++++++-------- src/locales/de.ts | 2 + src/locales/en.ts | 2 + src/locales/es.ts | 2 + src/locales/fr.ts | 2 + src/locales/nb.ts | 2 + src/locales/nl.ts | 2 + src/locales/ru.ts | 2 + src/locales/zh.ts | 2 + src/pages/ArtistDetail.tsx | 2 +- src/pages/Favorites.tsx | 2 +- src/pages/PlaylistDetail.tsx | 5 +- src/pages/RandomMix.tsx | 4 +- src/store/previewStore.ts | 28 +++++++++-- src/styles/components.css | 72 ++++++++++++++++++++++++++ 18 files changed, 204 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index ffd9f792..37ebb53c 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -5412,12 +5412,30 @@ pub async fn audio_preview_play( #[tauri::command] pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) { + preview_stop_inner(&app, &state, true); +} + +/// Like `audio_preview_stop` but leaves the main sink paused even if it had +/// been paused by `preview_pause_main`. Used by the player-bar Stop button so +/// "stop everything" actually goes silent — without this the engine would +/// auto-resume main playback the moment the preview ends and the user perceives +/// the click as having no effect. +#[tauri::command] +pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) { + preview_stop_inner(&app, &state, false); +} + +fn preview_stop_inner(app: &AppHandle, state: &AudioEngine, resume_main: bool) { 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 resume_main { + preview_resume_main(state); + } else { + state.preview_main_resume.store(false, Ordering::Release); + } if let Some(id) = id { app.emit("audio:preview-end", PreviewEndPayload { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43bd58b7..0f42c993 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4599,6 +4599,7 @@ pub fn run() { audio::audio_play_radio, audio::audio_preview_play, audio::audio_preview_stop, + audio::audio_preview_stop_silent, audio::audio_set_crossfade, audio::audio_set_gapless, audio::audio_set_normalization, diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index eb32d088..c454b826 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -160,7 +160,7 @@ const TrackRow = React.memo(function TrackRow({ className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`} onClick={e => { e.stopPropagation(); - usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'albums'); + usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums'); }} data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')} aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index a1e097bd..0a21307e 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -25,6 +25,7 @@ import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; +import { usePreviewStore } from '../store/previewStore'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -144,6 +145,8 @@ export default function PlayerBar() { const transportAnchorRef = useRef(null); const playSlotRef = useRef(null); const scheduleRemaining = usePlaybackScheduleRemaining(); + const isPreviewing = usePreviewStore(s => s.previewingId !== null); + const previewingTrack = usePreviewStore(s => s.previewingTrack); const isRadio = !!currentRadio; @@ -176,8 +179,15 @@ export default function PlayerBar() { [currentRadio?.coverArt, currentRadio?.id] ); const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : ''; - const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : ''; + // Preview takes visual priority over the queued track in the player-bar info + // cell, but only when not in radio mode (radio has its own meta layout). + const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null; + const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt; + const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle')); + const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—'); + + const coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]); + const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : ''; const handleVolume = useCallback((e: React.ChangeEvent) => { setVolume(parseFloat(e.target.value)); @@ -196,7 +206,7 @@ export default function PlayerBar() { const playerBarContent = ( <>