feat(player): preview indicator in player bar + smart stop semantics (#394)

* 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.
This commit is contained in:
Frank Stellmacher
2026-05-01 12:57:34 +02:00
committed by GitHub
parent 9cef3da1bb
commit 20a083a9a6
18 changed files with 204 additions and 32 deletions
+1 -1
View File
@@ -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')}
+64 -20
View File
@@ -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<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(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<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
@@ -196,7 +206,7 @@ export default function PlayerBar() {
const playerBarContent = (
<>
<footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
aria-label={t('player.regionLabel')}
@@ -205,9 +215,9 @@ export default function PlayerBar() {
{/* Track Info */}
<div className="player-track-info">
<div
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
className={`player-album-art-wrap ${currentTrack && !isRadio && !showPreviewMeta ? 'clickable' : ''}`}
onClick={() => !isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
>
{isRadio ? (
currentRadio?.coverArt ? (
@@ -222,25 +232,30 @@ export default function PlayerBar() {
<Cast size={20} />
</div>
)
) : currentTrack?.coverArt ? (
) : displayCoverArt ? (
<CachedImage
className="player-album-art"
src={coverSrc}
cacheKey={coverKey}
alt={`${currentTrack.album} Cover`}
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
/>
) : (
<div className="player-album-art-placeholder">
<Music size={22} />
</div>
)}
{currentTrack && !isRadio && (
{currentTrack && !isRadio && !showPreviewMeta && (
<div className="player-art-expand-hint" aria-hidden="true">
<Maximize2 size={16} />
</div>
)}
</div>
<div className="player-track-meta">
{showPreviewMeta && (
<span className="player-preview-label" aria-label={t('player.previewActive')}>
{t('player.previewLabel')}
</span>
)}
<MarqueeText
text={isRadio
? (radioMeta.currentTitle
@@ -248,22 +263,22 @@ export default function PlayerBar() {
? `${radioMeta.currentArtist}${radioMeta.currentTitle}`
: radioMeta.currentTitle)
: (currentRadio?.name ?? '—'))
: (currentTrack?.title ?? t('player.noTitle'))}
: displayTitle}
className="player-track-name"
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={isRadio
? (radioMeta.currentTitle && currentRadio?.name
? currentRadio.name
: t('radio.liveStream'))
: (currentTrack?.artist ?? '—')}
: displayArtist}
className="player-track-artist"
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
{currentTrack && !isRadio && (
{currentTrack && !isRadio && !showPreviewMeta && (
<StarRating
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
@@ -303,7 +318,19 @@ export default function PlayerBar() {
{/* Transport Controls */}
<div className="player-buttons" ref={transportAnchorRef}>
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
<button
className="player-btn player-btn-sm"
onClick={() => {
if (isPreviewing) {
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
invoke('audio_preview_stop_silent').catch(() => {});
} else {
stop();
}
}}
aria-label={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
>
<Square size={14} fill="currentColor" />
</button>
<button className="player-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
@@ -311,12 +338,27 @@ export default function PlayerBar() {
</button>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
{isPreviewing && (
<svg className="player-btn-preview-ring" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="47" pathLength="100" className="player-btn-preview-ring-track" />
<circle cx="50" cy="50" r="47" pathLength="100" className="player-btn-preview-ring-progress" />
</svg>
)}
<button
className="player-btn player-btn-primary"
className={`player-btn player-btn-primary${isPreviewing ? ' is-previewing' : ''}`}
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
onClick={isPreviewing
? (() => {
// Visual is "stop preview"; semantics match the tracklist preview
// button — preview ends, main playback auto-resumes if it was
// playing before. Use regular audio_preview_stop (not _silent).
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
invoke('audio_preview_stop').catch(() => {});
})
: playPauseBind.onClick}
aria-label={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
@@ -325,6 +367,8 @@ export default function PlayerBar() {
: <Sunrise size={10} strokeWidth={2.5} />}
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
</span>
) : isPreviewing ? (
<Square size={16} fill="currentColor" strokeWidth={0} />
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
</span>