mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
9cef3da1bb
commit
20a083a9a6
+19
-1
@@ -5412,12 +5412,30 @@ pub async fn audio_preview_play(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
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);
|
state.preview_gen.fetch_add(1, Ordering::SeqCst);
|
||||||
let sink = state.preview_sink.lock().unwrap().take();
|
let sink = state.preview_sink.lock().unwrap().take();
|
||||||
let id = state.preview_song_id.lock().unwrap().take();
|
let id = state.preview_song_id.lock().unwrap().take();
|
||||||
if let Some(s) = sink { s.stop(); }
|
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 {
|
if let Some(id) = id {
|
||||||
app.emit("audio:preview-end", PreviewEndPayload {
|
app.emit("audio:preview-end", PreviewEndPayload {
|
||||||
|
|||||||
@@ -4599,6 +4599,7 @@ pub fn run() {
|
|||||||
audio::audio_play_radio,
|
audio::audio_play_radio,
|
||||||
audio::audio_preview_play,
|
audio::audio_preview_play,
|
||||||
audio::audio_preview_stop,
|
audio::audio_preview_stop,
|
||||||
|
audio::audio_preview_stop_silent,
|
||||||
audio::audio_set_crossfade,
|
audio::audio_set_crossfade,
|
||||||
audio::audio_set_gapless,
|
audio::audio_set_gapless,
|
||||||
audio::audio_set_normalization,
|
audio::audio_set_normalization,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const TrackRow = React.memo(function TrackRow({
|
|||||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
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')}
|
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
|||||||
import PlaybackDelayModal from './PlaybackDelayModal';
|
import PlaybackDelayModal from './PlaybackDelayModal';
|
||||||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||||||
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
||||||
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
@@ -144,6 +145,8 @@ export default function PlayerBar() {
|
|||||||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||||
|
const isPreviewing = usePreviewStore(s => s.previewingId !== null);
|
||||||
|
const previewingTrack = usePreviewStore(s => s.previewingTrack);
|
||||||
|
|
||||||
const isRadio = !!currentRadio;
|
const isRadio = !!currentRadio;
|
||||||
|
|
||||||
@@ -176,8 +179,15 @@ export default function PlayerBar() {
|
|||||||
[currentRadio?.coverArt, currentRadio?.id]
|
[currentRadio?.coverArt, currentRadio?.id]
|
||||||
);
|
);
|
||||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
||||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
// Preview takes visual priority over the queued track in the player-bar info
|
||||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
// 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>) => {
|
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setVolume(parseFloat(e.target.value));
|
setVolume(parseFloat(e.target.value));
|
||||||
@@ -196,7 +206,7 @@ export default function PlayerBar() {
|
|||||||
const playerBarContent = (
|
const playerBarContent = (
|
||||||
<>
|
<>
|
||||||
<footer
|
<footer
|
||||||
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
|
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
|
||||||
style={floatingPlayerBar ? floatingStyle : undefined}
|
style={floatingPlayerBar ? floatingStyle : undefined}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('player.regionLabel')}
|
aria-label={t('player.regionLabel')}
|
||||||
@@ -205,9 +215,9 @@ export default function PlayerBar() {
|
|||||||
{/* Track Info */}
|
{/* Track Info */}
|
||||||
<div className="player-track-info">
|
<div className="player-track-info">
|
||||||
<div
|
<div
|
||||||
className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`}
|
className={`player-album-art-wrap ${currentTrack && !isRadio && !showPreviewMeta ? 'clickable' : ''}`}
|
||||||
onClick={() => !isRadio && currentTrack && toggleFullscreen()}
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()}
|
||||||
data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined}
|
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
||||||
>
|
>
|
||||||
{isRadio ? (
|
{isRadio ? (
|
||||||
currentRadio?.coverArt ? (
|
currentRadio?.coverArt ? (
|
||||||
@@ -222,25 +232,30 @@ export default function PlayerBar() {
|
|||||||
<Cast size={20} />
|
<Cast size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : currentTrack?.coverArt ? (
|
) : displayCoverArt ? (
|
||||||
<CachedImage
|
<CachedImage
|
||||||
className="player-album-art"
|
className="player-album-art"
|
||||||
src={coverSrc}
|
src={coverSrc}
|
||||||
cacheKey={coverKey}
|
cacheKey={coverKey}
|
||||||
alt={`${currentTrack.album} Cover`}
|
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="player-album-art-placeholder">
|
<div className="player-album-art-placeholder">
|
||||||
<Music size={22} />
|
<Music size={22} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentTrack && !isRadio && (
|
{currentTrack && !isRadio && !showPreviewMeta && (
|
||||||
<div className="player-art-expand-hint" aria-hidden="true">
|
<div className="player-art-expand-hint" aria-hidden="true">
|
||||||
<Maximize2 size={16} />
|
<Maximize2 size={16} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="player-track-meta">
|
<div className="player-track-meta">
|
||||||
|
{showPreviewMeta && (
|
||||||
|
<span className="player-preview-label" aria-label={t('player.previewActive')}>
|
||||||
|
{t('player.previewLabel')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<MarqueeText
|
<MarqueeText
|
||||||
text={isRadio
|
text={isRadio
|
||||||
? (radioMeta.currentTitle
|
? (radioMeta.currentTitle
|
||||||
@@ -248,22 +263,22 @@ export default function PlayerBar() {
|
|||||||
? `${radioMeta.currentArtist} — ${radioMeta.currentTitle}`
|
? `${radioMeta.currentArtist} — ${radioMeta.currentTitle}`
|
||||||
: radioMeta.currentTitle)
|
: radioMeta.currentTitle)
|
||||||
: (currentRadio?.name ?? '—'))
|
: (currentRadio?.name ?? '—'))
|
||||||
: (currentTrack?.title ?? t('player.noTitle'))}
|
: displayTitle}
|
||||||
className="player-track-name"
|
className="player-track-name"
|
||||||
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||||
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||||
/>
|
/>
|
||||||
<MarqueeText
|
<MarqueeText
|
||||||
text={isRadio
|
text={isRadio
|
||||||
? (radioMeta.currentTitle && currentRadio?.name
|
? (radioMeta.currentTitle && currentRadio?.name
|
||||||
? currentRadio.name
|
? currentRadio.name
|
||||||
: t('radio.liveStream'))
|
: t('radio.liveStream'))
|
||||||
: (currentTrack?.artist ?? '—')}
|
: displayArtist}
|
||||||
className="player-track-artist"
|
className="player-track-artist"
|
||||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
/>
|
/>
|
||||||
{currentTrack && !isRadio && (
|
{currentTrack && !isRadio && !showPreviewMeta && (
|
||||||
<StarRating
|
<StarRating
|
||||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||||
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||||
@@ -303,7 +318,19 @@ export default function PlayerBar() {
|
|||||||
|
|
||||||
{/* Transport Controls */}
|
{/* Transport Controls */}
|
||||||
<div className="player-buttons" ref={transportAnchorRef}>
|
<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" />
|
<Square size={14} fill="currentColor" />
|
||||||
</button>
|
</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}>
|
<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>
|
</button>
|
||||||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||||||
<PlaybackScheduleBadge layoutAnchorRef={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
|
<button
|
||||||
className="player-btn player-btn-primary"
|
className={`player-btn player-btn-primary${isPreviewing ? ' is-previewing' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
{...playPauseBind}
|
{...playPauseBind}
|
||||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
onClick={isPreviewing
|
||||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
? (() => {
|
||||||
|
// 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 ? (
|
{scheduleRemaining != null ? (
|
||||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
|
<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} />}
|
: <Sunrise size={10} strokeWidth={2.5} />}
|
||||||
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
|
<span className="player-btn-schedule-time">{scheduleRemaining.remaining}</span>
|
||||||
</span>
|
</span>
|
||||||
|
) : isPreviewing ? (
|
||||||
|
<Square size={16} fill="currentColor" strokeWidth={0} />
|
||||||
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1190,6 +1190,8 @@ export const deTranslation = {
|
|||||||
prev: 'Vorheriger Titel',
|
prev: 'Vorheriger Titel',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
|
previewActive: 'Vorschau läuft',
|
||||||
|
previewLabel: 'Vorschau',
|
||||||
delayModalTitle: 'Timer',
|
delayModalTitle: 'Timer',
|
||||||
delayPauseSection: 'Pause nach',
|
delayPauseSection: 'Pause nach',
|
||||||
delayStartSection: 'Start nach',
|
delayStartSection: 'Start nach',
|
||||||
|
|||||||
@@ -1196,6 +1196,8 @@ export const enTranslation = {
|
|||||||
prev: 'Previous Track',
|
prev: 'Previous Track',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
|
previewActive: 'Preview playing',
|
||||||
|
previewLabel: 'Preview',
|
||||||
delayModalTitle: 'Timer',
|
delayModalTitle: 'Timer',
|
||||||
delayPauseSection: 'Pause after',
|
delayPauseSection: 'Pause after',
|
||||||
delayStartSection: 'Start after',
|
delayStartSection: 'Start after',
|
||||||
|
|||||||
@@ -1183,6 +1183,8 @@ export const esTranslation = {
|
|||||||
prev: 'Pista Anterior',
|
prev: 'Pista Anterior',
|
||||||
play: 'Reproducir',
|
play: 'Reproducir',
|
||||||
pause: 'Pausa',
|
pause: 'Pausa',
|
||||||
|
previewActive: 'Vista previa activa',
|
||||||
|
previewLabel: 'Vista previa',
|
||||||
delayModalTitle: 'Temporizador',
|
delayModalTitle: 'Temporizador',
|
||||||
delayPauseSection: 'Pausar después de',
|
delayPauseSection: 'Pausar después de',
|
||||||
delayStartSection: 'Iniciar después de',
|
delayStartSection: 'Iniciar después de',
|
||||||
|
|||||||
@@ -1178,6 +1178,8 @@ export const frTranslation = {
|
|||||||
prev: 'Piste précédente',
|
prev: 'Piste précédente',
|
||||||
play: 'Lecture',
|
play: 'Lecture',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
|
previewActive: 'Aperçu en cours',
|
||||||
|
previewLabel: 'Aperçu',
|
||||||
delayModalTitle: 'Minuteur',
|
delayModalTitle: 'Minuteur',
|
||||||
delayPauseSection: 'Pause après',
|
delayPauseSection: 'Pause après',
|
||||||
delayStartSection: 'Démarrage après',
|
delayStartSection: 'Démarrage après',
|
||||||
|
|||||||
@@ -1177,6 +1177,8 @@ export const nbTranslation = {
|
|||||||
prev: 'Forrige spor',
|
prev: 'Forrige spor',
|
||||||
play: 'Spill av',
|
play: 'Spill av',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
|
previewActive: 'Forhåndsvisning spilles av',
|
||||||
|
previewLabel: 'Forhåndsvisning',
|
||||||
delayModalTitle: 'Tidsur',
|
delayModalTitle: 'Tidsur',
|
||||||
delayPauseSection: 'Pause etter',
|
delayPauseSection: 'Pause etter',
|
||||||
delayStartSection: 'Start etter',
|
delayStartSection: 'Start etter',
|
||||||
|
|||||||
@@ -1177,6 +1177,8 @@ export const nlTranslation = {
|
|||||||
prev: 'Vorig nummer',
|
prev: 'Vorig nummer',
|
||||||
play: 'Afspelen',
|
play: 'Afspelen',
|
||||||
pause: 'Pauzeren',
|
pause: 'Pauzeren',
|
||||||
|
previewActive: 'Voorbeeld speelt af',
|
||||||
|
previewLabel: 'Voorbeeld',
|
||||||
delayModalTitle: 'Timer',
|
delayModalTitle: 'Timer',
|
||||||
delayPauseSection: 'Pauzeren na',
|
delayPauseSection: 'Pauzeren na',
|
||||||
delayStartSection: 'Starten na',
|
delayStartSection: 'Starten na',
|
||||||
|
|||||||
@@ -1263,6 +1263,8 @@ export const ruTranslation = {
|
|||||||
prev: 'Предыдущий трек',
|
prev: 'Предыдущий трек',
|
||||||
play: 'Играть',
|
play: 'Играть',
|
||||||
pause: 'Пауза',
|
pause: 'Пауза',
|
||||||
|
previewActive: 'Превью воспроизводится',
|
||||||
|
previewLabel: 'Превью',
|
||||||
delayModalTitle: 'Таймер',
|
delayModalTitle: 'Таймер',
|
||||||
delayPauseSection: 'Пауза через',
|
delayPauseSection: 'Пауза через',
|
||||||
delayStartSection: 'Старт через',
|
delayStartSection: 'Старт через',
|
||||||
|
|||||||
@@ -1172,6 +1172,8 @@ export const zhTranslation = {
|
|||||||
prev: '上一首',
|
prev: '上一首',
|
||||||
play: '播放',
|
play: '播放',
|
||||||
pause: '暂停',
|
pause: '暂停',
|
||||||
|
previewActive: '正在试听',
|
||||||
|
previewLabel: '试听',
|
||||||
delayModalTitle: '定时',
|
delayModalTitle: '定时',
|
||||||
delayPauseSection: '多久后暂停',
|
delayPauseSection: '多久后暂停',
|
||||||
delayStartSection: '多久后开始',
|
delayStartSection: '多久后开始',
|
||||||
|
|||||||
@@ -738,7 +738,7 @@ export default function ArtistDetail() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'artist'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export default function Favorites() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'favorites'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'favorites'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -938,6 +938,9 @@ export default function PlaylistDetail() {
|
|||||||
const startPreview = useCallback((song: SubsonicSong) => {
|
const startPreview = useCallback((song: SubsonicSong) => {
|
||||||
usePreviewStore.getState().startPreview({
|
usePreviewStore.getState().startPreview({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
coverArt: song.coverArt,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1709,7 +1712,7 @@ export default function PlaylistDetail() {
|
|||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'playlists');
|
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'playlists');
|
||||||
}}
|
}}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export default function RandomMix() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'randomMix'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
>
|
>
|
||||||
@@ -604,7 +604,7 @@ export default function RandomMix() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, duration: song.duration }, 'randomMix'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'randomMix'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ import { buildStreamUrl } from '../api/subsonic';
|
|||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
import { useAuthStore, type TrackPreviewLocation } from './authStore';
|
||||||
|
|
||||||
|
/** Minimal track info needed to surface the preview in the player bar UI. */
|
||||||
|
export interface PreviewingTrack {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
coverArt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
/** Subsonic song id of the active preview, or null when nothing previews. */
|
/** Subsonic song id of the active preview, or null when nothing previews. */
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
/** Currently previewing track with the metadata the player-bar UI needs. */
|
||||||
|
previewingTrack: PreviewingTrack | null;
|
||||||
/** Seconds elapsed in the current preview window. */
|
/** Seconds elapsed in the current preview window. */
|
||||||
elapsed: number;
|
elapsed: number;
|
||||||
/** Total preview window in seconds (echoes the duration_sec arg). */
|
/** Total preview window in seconds (echoes the duration_sec arg). */
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
startPreview: (song: { id: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
||||||
stopPreview: () => Promise<void>;
|
stopPreview: () => Promise<void>;
|
||||||
|
|
||||||
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
||||||
@@ -27,6 +37,7 @@ const PREVIEW_VOLUME_MATCH = true;
|
|||||||
|
|
||||||
export const usePreviewStore = create<PreviewState>((set, get) => ({
|
export const usePreviewStore = create<PreviewState>((set, get) => ({
|
||||||
previewingId: null,
|
previewingId: null,
|
||||||
|
previewingTrack: null,
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
duration: 30,
|
duration: 30,
|
||||||
|
|
||||||
@@ -61,7 +72,12 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ previewingId: song.id, elapsed: 0, duration: previewDuration });
|
set({
|
||||||
|
previewingId: song.id,
|
||||||
|
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
|
||||||
|
elapsed: 0,
|
||||||
|
duration: previewDuration,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('audio_preview_play', {
|
await invoke('audio_preview_play', {
|
||||||
@@ -74,7 +90,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Roll back optimistic state on failure.
|
// Roll back optimistic state on failure.
|
||||||
if (get().previewingId === song.id) {
|
if (get().previewingId === song.id) {
|
||||||
set({ previewingId: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -86,12 +102,14 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
await invoke('audio_preview_stop');
|
await invoke('audio_preview_stop');
|
||||||
} catch {
|
} catch {
|
||||||
/* engine will emit preview-end anyway; clear locally as fallback */
|
/* engine will emit preview-end anyway; clear locally as fallback */
|
||||||
set({ previewingId: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_onStart: (id) => {
|
_onStart: (id) => {
|
||||||
if (get().previewingId !== id) {
|
if (get().previewingId !== id) {
|
||||||
|
// Engine fired start for an id we didn't track locally — keep id but
|
||||||
|
// leave previewingTrack as-is (the caller's startPreview() set it).
|
||||||
set({ previewingId: id, elapsed: 0 });
|
set({ previewingId: id, elapsed: 0 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -103,6 +121,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
|
|
||||||
_onEnd: (id) => {
|
_onEnd: (id) => {
|
||||||
if (get().previewingId !== id) return;
|
if (get().previewingId !== id) return;
|
||||||
set({ previewingId: null, elapsed: 0 });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2321,6 +2321,78 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Preview-active state on the player bar ─────────────────────────
|
||||||
|
Combo of two cues so it's unmistakable that a preview is sounding:
|
||||||
|
1) accent top-border on the bar itself
|
||||||
|
2) tiny "Preview" pill above the title in the track-info cell */
|
||||||
|
.player-bar.is-previewing {
|
||||||
|
box-shadow: inset 0 2px 0 0 var(--accent), 0 -1px 0 0 var(--bg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-preview-label {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview-active state on the play button ────────────────────────
|
||||||
|
While a track preview is sounding, the player-bar play button mirrors
|
||||||
|
the inline preview button used in tracklists: hollow circle + chevron
|
||||||
|
icon + countdown ring. Click still resumes main playback (which the
|
||||||
|
Rust audio engine cancels the preview for, see audio.rs). */
|
||||||
|
.player-btn-primary.is-previewing {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-primary.is-previewing:hover {
|
||||||
|
background: var(--bg-hover, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-preview-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-preview-ring-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ctp-overlay0);
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-preview-ring-progress {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 100;
|
||||||
|
stroke-dashoffset: 100;
|
||||||
|
animation: player-preview-progress var(--preview-duration, 30s) linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes player-preview-progress {
|
||||||
|
from { stroke-dashoffset: 100; }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Custom-minutes input with inline "min" suffix ──────────────────── */
|
/* ── Custom-minutes input with inline "min" suffix ──────────────────── */
|
||||||
.playback-delay-custom__field {
|
.playback-delay-custom__field {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user