mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
a6cc2e2ad4
* feat(linux): optional native GDK for Nix gdk-session Introduce PSYSONIC_ALLOW_NATIVE_GDK so main skips the default GDK_BACKEND=x11 pin when the Nix gdk-session wrapper sets the flag. Remove GDK_BACKEND from the npm tauri:dev script so it does not override nix develop defaults. * fix(ui): portal server switch menu above sidebar Main column stacks below the sidebar (layout z-index), so an in-tree dropdown could never win over the left nav. Render the menu via createPortal to document.body with fixed coordinates, matching the library scope picker. * feat(perf): add mainstage probe controls and cut WebKit repaint load Add a dedicated performance probe surface for mainstage/home toggles and wire Linux CPU diagnostics to isolate expensive UI paths. Tune waveform drawing and Home artwork clipping/windowing so visible content loads immediately while reducing WebKit compositor pressure during playback. * fix(perf): stop hero rotation when section is off-screen Gate hero auto-rotation and backdrop crossfade by real viewport visibility using the actual scrolling ancestor. This prevents periodic 10-second CPU spikes from hidden hero updates while preserving normal behavior when the hero is visible. * fix(perf): isolate player progress updates from mainstage diagnostics Add probe toggles for PlayerBar waveform and live progress UI updates to confirm playback progress churn as the main CPU driver. Restore Home artwork quality defaults and keep visual-degradation modes opt-in via debug flags only. * fix(hero): resume background and autoplay after viewport return Re-check hero visibility on focus/visibility changes and add a short recovery poll while off-screen so missed scroll/RAF events cannot leave hero animation paused. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(perf): decouple playback progress from mainstage compositing pressure Throttle audio progress delivery and route live seekbar timing through a lightweight progress channel to cut focus-time WebKit CPU spikes. Add focused diagnostics in Performance Probe and restore hero/waveform behavior so visuals remain stable while profiling. * fix(debug): open performance probe with Ctrl+Shift+D Replace logo-triggered opening with a keyboard shortcut and keep logo purely decorative to avoid accidental probe activation. * docs(changelog): document experiment/performance probe and playback work Add an [Unreleased] section for the performance probe, throttled audio progress IPC, snapshot-based live UI updates, WaveformSeek scheduling over the same canvas bar renderer, Hero/Home rail fixes, and Linux/Nix GDK dev ergonomics. * perf(linux): add WebKit probe, throttle progress IPC, snapshot playback UI Ship Performance Probe (Ctrl+Shift+D), Rust-throttled audio:progress, a playback progress snapshot channel with coarse Zustand timeline commits, Linux /proc CPU readout for the probe, Hero and Home rail artwork fixes, Tracks SongRail windowing parity, MPRIS cleanup, gated perf counters, and WaveformSeek paused-seek correctness. Documented in CHANGELOG for PR #452. * docs(changelog): fold perf work into 1.45.0 and refresh date Drop the separate 1.45.1 heading; keep PR #452 notes under 1.45.0 Added and set the section date to 2026-05-04. Restore the safety preface before the versioned sections. * docs(changelog): order 1.45.0 Added entries by PR number Sort the 1.45.0 release notes so subsections follow ascending PR id (390 through 452), with PR #452 last. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
783 lines
32 KiB
TypeScript
783 lines
32 KiB
TypeScript
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
|
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
|
|
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
|
|
} from 'lucide-react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playerStore';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useThemeStore } from '../store/themeStore';
|
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } from '../api/subsonic';
|
|
import CachedImage from './CachedImage';
|
|
import WaveformSeek from './WaveformSeek';
|
|
import Equalizer from './Equalizer';
|
|
import StarRating from './StarRating';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useLyricsStore } from '../store/lyricsStore';
|
|
import MarqueeText from './MarqueeText';
|
|
import LastfmIcon from './LastfmIcon';
|
|
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
|
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
|
import PlaybackDelayModal from './PlaybackDelayModal';
|
|
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
|
import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat';
|
|
import { usePreviewStore } from '../store/previewStore';
|
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Renders the playback clock without ever causing PlayerBar to re-render.
|
|
// Updates the DOM directly via an imperative store subscription.
|
|
const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
|
|
const spanRef = useRef<HTMLSpanElement>(null);
|
|
useEffect(() => {
|
|
if (spanRef.current) {
|
|
spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime);
|
|
}
|
|
return subscribePlaybackProgress(state => {
|
|
if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime);
|
|
});
|
|
}, []);
|
|
return <span className={className} ref={spanRef} />;
|
|
});
|
|
|
|
// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render.
|
|
const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
|
|
const spanRef = useRef<HTMLSpanElement>(null);
|
|
useEffect(() => {
|
|
const updateRemaining = () => {
|
|
if (spanRef.current) {
|
|
const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
|
|
spanRef.current.textContent = `-${formatTime(remaining)}`;
|
|
}
|
|
};
|
|
updateRemaining();
|
|
return subscribePlaybackProgress(updateRemaining);
|
|
}, [duration]);
|
|
return <span className={className} ref={spanRef} />;
|
|
});
|
|
|
|
export default function PlayerBar() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [eqOpen, setEqOpen] = useState(false);
|
|
const [showVolPct, setShowVolPct] = useState(false);
|
|
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
|
const premuteVolumeRef = useRef(1);
|
|
const showLyrics = useLyricsStore(s => s.showLyrics);
|
|
const activeTab = useLyricsStore(s => s.activeTab);
|
|
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
|
|
const {
|
|
currentTrack, currentRadio, isPlaying, volume,
|
|
togglePlay, next, previous, setVolume,
|
|
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
|
lastfmLoved, toggleLastfmLove,
|
|
isQueueVisible, toggleQueue,
|
|
starredOverrides, setStarredOverride,
|
|
userRatingOverrides, setUserRatingOverride,
|
|
} = usePlayerStore(useShallow(s => ({
|
|
currentTrack: s.currentTrack,
|
|
currentRadio: s.currentRadio,
|
|
isPlaying: s.isPlaying,
|
|
volume: s.volume,
|
|
togglePlay: s.togglePlay,
|
|
next: s.next,
|
|
previous: s.previous,
|
|
setVolume: s.setVolume,
|
|
stop: s.stop,
|
|
toggleRepeat: s.toggleRepeat,
|
|
repeatMode: s.repeatMode,
|
|
toggleFullscreen: s.toggleFullscreen,
|
|
lastfmLoved: s.lastfmLoved,
|
|
toggleLastfmLove: s.toggleLastfmLove,
|
|
isQueueVisible: s.isQueueVisible,
|
|
toggleQueue: s.toggleQueue,
|
|
starredOverrides: s.starredOverrides,
|
|
setStarredOverride: s.setStarredOverride,
|
|
userRatingOverrides: s.userRatingOverrides,
|
|
setUserRatingOverride: s.setUserRatingOverride,
|
|
})));
|
|
const { lastfmSessionKey } = useAuthStore();
|
|
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
|
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
|
|
const playerBarRef = useRef<HTMLElement>(null);
|
|
const [utilityOverflow, setUtilityOverflow] = useState(false);
|
|
const [utilityMenuOpen, setUtilityMenuOpen] = useState(false);
|
|
const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full');
|
|
const utilityMenuRef = useRef<HTMLDivElement>(null);
|
|
const utilityBtnRef = useRef<HTMLButtonElement>(null);
|
|
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
|
|
const volumeWheelMenuTimerRef = useRef<number | null>(null);
|
|
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
|
|
const perfFlags = usePerfProbeFlags();
|
|
|
|
useEffect(() => {
|
|
if (!floatingPlayerBar) return;
|
|
|
|
const updatePosition = () => {
|
|
const sidebar = document.querySelector('.sidebar') as HTMLElement;
|
|
const queue = document.querySelector('.queue-panel') as HTMLElement;
|
|
|
|
const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0;
|
|
const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0;
|
|
|
|
setFloatingStyle({
|
|
left: leftOffset + 24,
|
|
right: rightOffset + 24,
|
|
width: 'auto',
|
|
});
|
|
};
|
|
|
|
updatePosition();
|
|
|
|
const observer = new ResizeObserver(updatePosition);
|
|
const sidebar = document.querySelector('.sidebar');
|
|
const queue = document.querySelector('.queue-panel');
|
|
if (sidebar) observer.observe(sidebar);
|
|
if (queue) observer.observe(queue);
|
|
window.addEventListener('resize', updatePosition);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
window.removeEventListener('resize', updatePosition);
|
|
};
|
|
}, [floatingPlayerBar]);
|
|
|
|
useEffect(() => {
|
|
const updateOverflow = () => {
|
|
const width = playerBarRef.current?.clientWidth ?? window.innerWidth;
|
|
const threshold = floatingPlayerBar ? 980 : 1140;
|
|
setUtilityOverflow(width < threshold);
|
|
};
|
|
|
|
updateOverflow();
|
|
const ro = typeof ResizeObserver !== 'undefined'
|
|
? new ResizeObserver(updateOverflow)
|
|
: null;
|
|
const el = playerBarRef.current;
|
|
if (ro && el) ro.observe(el);
|
|
window.addEventListener('resize', updateOverflow);
|
|
return () => {
|
|
ro?.disconnect();
|
|
window.removeEventListener('resize', updateOverflow);
|
|
};
|
|
}, [floatingPlayerBar]);
|
|
|
|
useEffect(() => {
|
|
if (!utilityOverflow) setUtilityMenuOpen(false);
|
|
if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
volumeWheelMenuTimerRef.current = null;
|
|
}
|
|
}, [utilityOverflow]);
|
|
|
|
useEffect(() => {
|
|
if (!utilityMenuOpen) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
const target = e.target as Node;
|
|
if (utilityBtnRef.current?.contains(target)) return;
|
|
if (utilityMenuRef.current?.contains(target)) return;
|
|
setUtilityMenuOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setUtilityMenuOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDown);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDown);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [utilityMenuOpen]);
|
|
|
|
useEffect(() => () => {
|
|
if (volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onToggleEqualizer = () => setEqOpen(v => !v);
|
|
window.addEventListener('psy:toggle-equalizer', onToggleEqualizer);
|
|
return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!utilityMenuOpen) return;
|
|
const MENU_WIDTH = 238;
|
|
const MARGIN = 8;
|
|
const updateMenuPos = () => {
|
|
const btn = utilityBtnRef.current;
|
|
if (!btn) return;
|
|
const r = btn.getBoundingClientRect();
|
|
const left = Math.min(
|
|
Math.max(r.right - MENU_WIDTH, MARGIN),
|
|
window.innerWidth - MENU_WIDTH - MARGIN,
|
|
);
|
|
setUtilityMenuStyle({
|
|
position: 'fixed',
|
|
left,
|
|
width: MENU_WIDTH,
|
|
bottom: window.innerHeight - r.top + 8,
|
|
zIndex: 10050,
|
|
});
|
|
};
|
|
updateMenuPos();
|
|
window.addEventListener('resize', updateMenuPos);
|
|
window.addEventListener('scroll', updateMenuPos, true);
|
|
return () => {
|
|
window.removeEventListener('resize', updateMenuPos);
|
|
window.removeEventListener('scroll', updateMenuPos, true);
|
|
};
|
|
}, [utilityMenuOpen]);
|
|
|
|
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
|
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
|
const playSlotRef = useRef<HTMLSpanElement>(null);
|
|
const scheduleRemaining = usePlaybackScheduleRemaining();
|
|
const isPreviewing = usePreviewStore(s => s.previewingId !== null);
|
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
|
const previewingTrack = usePreviewStore(s => s.previewingTrack);
|
|
|
|
const isRadio = !!currentRadio;
|
|
|
|
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
|
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
|
|
|
|
|
const isStarred = currentTrack
|
|
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
|
: false;
|
|
|
|
const toggleStar = useCallback(async () => {
|
|
if (!currentTrack) return;
|
|
const next = !isStarred;
|
|
setStarredOverride(currentTrack.id, next);
|
|
try {
|
|
if (next) await star(currentTrack.id, 'song');
|
|
else await unstar(currentTrack.id, 'song');
|
|
} catch {
|
|
setStarredOverride(currentTrack.id, !next);
|
|
}
|
|
}, [currentTrack, isStarred, setStarredOverride]);
|
|
|
|
const duration = currentTrack?.duration ?? 0;
|
|
|
|
// Cover art: prefer radio station art, fall back to track art.
|
|
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
|
|
const radioCoverSrc = useMemo(
|
|
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
|
|
[currentRadio?.coverArt, currentRadio?.id]
|
|
);
|
|
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 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));
|
|
}, [setVolume]);
|
|
|
|
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
|
setVolume(Math.max(0, Math.min(1, volume + delta)));
|
|
|
|
if (utilityOverflow) {
|
|
setSuppressOverflowTooltip(true);
|
|
setUtilityMenuMode('volume');
|
|
setUtilityMenuOpen(true);
|
|
if (volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
}
|
|
volumeWheelMenuTimerRef.current = window.setTimeout(() => {
|
|
setUtilityMenuOpen(false);
|
|
setSuppressOverflowTooltip(false);
|
|
volumeWheelMenuTimerRef.current = null;
|
|
}, 1000);
|
|
}
|
|
}, [volume, setVolume, utilityOverflow]);
|
|
|
|
const volumeStyle = {
|
|
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
|
};
|
|
|
|
const playerBarContent = (
|
|
<>
|
|
<footer
|
|
ref={playerBarRef}
|
|
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}${showPreviewMeta && previewAudioStarted ? ' audio-started' : ''}`}
|
|
style={floatingPlayerBar ? floatingStyle : undefined}
|
|
role="region"
|
|
aria-label={t('player.regionLabel')}
|
|
>
|
|
|
|
{/* Track Info */}
|
|
<div className="player-track-info">
|
|
<div
|
|
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 ? (
|
|
<CachedImage
|
|
className="player-album-art"
|
|
src={radioCoverSrc}
|
|
cacheKey={radioCoverKey}
|
|
alt={currentRadio.name}
|
|
/>
|
|
) : (
|
|
<div className="player-album-art-placeholder">
|
|
<Cast size={20} />
|
|
</div>
|
|
)
|
|
) : displayCoverArt ? (
|
|
<CachedImage
|
|
className="player-album-art"
|
|
src={coverSrc}
|
|
cacheKey={coverKey}
|
|
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
|
/>
|
|
) : (
|
|
<div className="player-album-art-placeholder">
|
|
<Music size={22} />
|
|
</div>
|
|
)}
|
|
{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
|
|
? (radioMeta.currentArtist
|
|
? `${radioMeta.currentArtist} — ${radioMeta.currentTitle}`
|
|
: radioMeta.currentTitle)
|
|
: (currentRadio?.name ?? '—'))
|
|
: displayTitle}
|
|
className="player-track-name"
|
|
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'))
|
|
: displayArtist}
|
|
className="player-track-artist"
|
|
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.artistId ? 'pointer' : 'default' }}
|
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
|
/>
|
|
{currentTrack && !isRadio && !showPreviewMeta && (
|
|
<StarRating
|
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
|
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
|
className="player-track-rating"
|
|
ariaLabel={t('albumDetail.ratingLabel')}
|
|
/>
|
|
)}
|
|
{isRadio && radioMeta.listeners != null && (
|
|
<span className="player-radio-listeners">
|
|
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{currentTrack && !isRadio && (
|
|
<button
|
|
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
|
onClick={toggleStar}
|
|
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
|
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
|
</button>
|
|
)}
|
|
{currentTrack && !isRadio && lastfmSessionKey && (
|
|
<button
|
|
className="player-btn player-btn-sm player-love-btn"
|
|
onClick={toggleLastfmLove}
|
|
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
|
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
|
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
<LastfmIcon size={15} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transport Controls */}
|
|
<div className="player-buttons" ref={transportAnchorRef}>
|
|
<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}>
|
|
<SkipBack size={19} />
|
|
</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${isPreviewing ? ' is-previewing' : ''}`}
|
|
type="button"
|
|
{...playPauseBind}
|
|
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}`}>
|
|
{scheduleRemaining.mode === 'pause'
|
|
? <Moon size={10} strokeWidth={2.5} />
|
|
: <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>
|
|
<button className="player-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')} disabled={isRadio} style={isRadio ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
|
<SkipForward size={19} />
|
|
</button>
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={toggleRepeat}
|
|
aria-label={t('player.repeat')}
|
|
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
|
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
|
>
|
|
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Waveform Seekbar / Radio live bar */}
|
|
<div className="player-waveform-section">
|
|
{isRadio ? (
|
|
<>
|
|
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
|
|
<>
|
|
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
|
|
<div className="player-waveform-wrap">
|
|
<div className="radio-progress-bar">
|
|
<div
|
|
className="radio-progress-fill"
|
|
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="player-time">{formatTime(radioMeta.duration)}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlaybackTime className="player-time" />
|
|
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<span className="radio-live-badge">{t('radio.live')}</span>
|
|
</div>
|
|
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlaybackTime className="player-time" />
|
|
<div className="player-waveform-wrap">
|
|
{perfFlags.disableWaveformCanvas
|
|
? <div className="radio-progress-bar" aria-hidden />
|
|
: <WaveformSeek trackId={currentTrack?.id} />}
|
|
</div>
|
|
<span
|
|
className="player-time player-time-toggle"
|
|
onClick={() => {
|
|
const newVal = !localShowRemaining;
|
|
setLocalShowRemaining(newVal);
|
|
useThemeStore.getState().setShowRemainingTime(newVal);
|
|
}}
|
|
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
|
|
>
|
|
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTime(duration)}
|
|
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{utilityOverflow ? (
|
|
<div className="player-overflow-wrap">
|
|
<button
|
|
ref={utilityBtnRef}
|
|
className={`player-btn player-btn-sm${utilityMenuOpen ? ' active' : ''}`}
|
|
onClick={() => {
|
|
setUtilityMenuMode('full');
|
|
setUtilityMenuOpen(v => !v);
|
|
if (volumeWheelMenuTimerRef.current != null) {
|
|
window.clearTimeout(volumeWheelMenuTimerRef.current);
|
|
volumeWheelMenuTimerRef.current = null;
|
|
}
|
|
setSuppressOverflowTooltip(false);
|
|
}}
|
|
onWheel={handleVolumeWheel}
|
|
aria-label={t('player.moreOptions')}
|
|
data-tooltip={suppressOverflowTooltip ? undefined : t('player.moreOptions')}
|
|
>
|
|
<Ellipsis size={15} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* EQ Button */}
|
|
<button
|
|
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
|
onClick={() => setEqOpen(v => !v)}
|
|
aria-label={t('player.equalizer')}
|
|
data-tooltip={t('player.equalizer')}
|
|
>
|
|
<SlidersVertical size={15} />
|
|
</button>
|
|
|
|
{/* Mini Player */}
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={() => invoke('open_mini_player').catch(() => {})}
|
|
aria-label={t('player.miniPlayer')}
|
|
data-tooltip={t('player.miniPlayer')}
|
|
>
|
|
<PictureInPicture2 size={15} />
|
|
</button>
|
|
|
|
{/* Volume */}
|
|
<div className="player-volume-section">
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={() => {
|
|
if (volume === 0) {
|
|
setVolume(premuteVolumeRef.current);
|
|
} else {
|
|
premuteVolumeRef.current = volume;
|
|
setVolume(0);
|
|
}
|
|
}}
|
|
aria-label={t('player.volume')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
|
</button>
|
|
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
|
|
{showVolPct && (
|
|
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
|
{Math.round(volume * 100)}%
|
|
</span>
|
|
)}
|
|
<input
|
|
type="range"
|
|
id="player-volume"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={handleVolume}
|
|
style={volumeStyle}
|
|
aria-label={t('player.volume')}
|
|
className="player-volume-slider"
|
|
onMouseEnter={() => setShowVolPct(true)}
|
|
onMouseLeave={() => setShowVolPct(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
|
|
{utilityMenuOpen && createPortal(
|
|
<div
|
|
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
|
|
ref={utilityMenuRef}
|
|
style={utilityMenuStyle}
|
|
onWheel={handleVolumeWheel}
|
|
>
|
|
{utilityMenuMode === 'full' && (
|
|
<div className="player-overflow-menu-row">
|
|
<button
|
|
className={`player-overflow-menu-btn${eqOpen ? ' active' : ''}`}
|
|
onClick={() => {
|
|
setEqOpen(v => !v);
|
|
setUtilityMenuOpen(false);
|
|
}}
|
|
>
|
|
<SlidersVertical size={14} />
|
|
{t('player.equalizer')}
|
|
</button>
|
|
<button
|
|
className="player-overflow-menu-btn"
|
|
onClick={() => {
|
|
invoke('open_mini_player').catch(() => {});
|
|
setUtilityMenuOpen(false);
|
|
}}
|
|
>
|
|
<PictureInPicture2 size={14} />
|
|
{t('player.miniPlayer')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{utilityMenuMode === 'full' ? (
|
|
<div className="player-volume-section player-volume-section--menu">
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={() => {
|
|
if (volume === 0) {
|
|
setVolume(premuteVolumeRef.current);
|
|
} else {
|
|
premuteVolumeRef.current = volume;
|
|
setVolume(0);
|
|
}
|
|
}}
|
|
aria-label={t('player.volume')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
|
</button>
|
|
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
|
|
{showVolPct && (
|
|
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
|
{Math.round(volume * 100)}%
|
|
</span>
|
|
)}
|
|
<input
|
|
type="range"
|
|
id="player-volume-overflow"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={handleVolume}
|
|
style={volumeStyle}
|
|
aria-label={t('player.volume')}
|
|
className="player-volume-slider"
|
|
onMouseEnter={() => setShowVolPct(true)}
|
|
onMouseLeave={() => setShowVolPct(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="player-volume-section player-volume-section--menu">
|
|
<button
|
|
className="player-btn player-btn-sm"
|
|
onClick={() => {
|
|
if (volume === 0) {
|
|
setVolume(premuteVolumeRef.current);
|
|
} else {
|
|
premuteVolumeRef.current = volume;
|
|
setVolume(0);
|
|
}
|
|
}}
|
|
aria-label={t('player.volume')}
|
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
|
>
|
|
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
|
</button>
|
|
<div className="player-volume-slider-wrap player-volume-slider-wrap--menu-only" onWheel={handleVolumeWheel}>
|
|
{showVolPct && (
|
|
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
|
{Math.round(volume * 100)}%
|
|
</span>
|
|
)}
|
|
<input
|
|
type="range"
|
|
id="player-volume-overflow-wheel"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={handleVolume}
|
|
style={volumeStyle}
|
|
aria-label={t('player.volume')}
|
|
className="player-volume-slider"
|
|
onMouseEnter={() => setShowVolPct(true)}
|
|
onMouseLeave={() => setShowVolPct(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
|
|
{eqOpen && createPortal(
|
|
<>
|
|
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
|
|
<div className="eq-popup">
|
|
<div className="eq-popup-header">
|
|
<span className="eq-popup-title">Equalizer</span>
|
|
<button className="eq-popup-close" onClick={() => setEqOpen(false)} aria-label="Close">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<Equalizer />
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
|
|
</footer>
|
|
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
|
</>
|
|
);
|
|
|
|
if (floatingPlayerBar) {
|
|
return createPortal(playerBarContent, document.body);
|
|
}
|
|
|
|
return playerBarContent;
|
|
}
|