mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(player-bar): H7 — split PlayerBar.tsx 802 → 354 LOC across 10 files (#670)
* refactor(player-bar): H7 — extract PlaybackTime + RemainingTime + formatTime The two memoized clock components (which update the DOM imperatively from the playbackProgress store without re-rendering PlayerBar) move into components/playerBar/PlaybackClock.tsx. formatTime helper → utils/playerBarHelpers.ts. PlayerBar.tsx: 802 → 765 LOC. * refactor(player-bar): H7 — extract PlayerTrackInfo The cover-art wrap + title/artist marquees + star + last.fm love buttons move into their own component. The new file uses PlayerState['openContextMenu'] for prop typing so the union literal type carries through. PlayerBar.tsx: 765 → 674 LOC. * refactor(player-bar): H7 — extract PlayerTransportControls Stop/Prev/Play/Next/Repeat buttons (with the preview-ring + schedule-badge overlays around play/pause) move into PlayerTransportControls.tsx. The component uses ReturnType<...> on the source hooks to derive its playPauseBind + scheduleRemaining prop types so the new file stays in lockstep with usePlaybackDelayPress + usePlaybackScheduleRemaining. PlayerBar.tsx: 674 → 621 LOC. * refactor(player-bar): H7 — extract PlayerSeekbarSection The waveform / radio progress / time-label block moves into its own component. PlayerSeekbarSection branches on isRadio (AzuraCast progress bar with elapsed+duration when available; LIVE badge otherwise) vs. regular track (WaveformSeek or perf-flag fallback + duration ↔ remaining toggle). PlayerBar.tsx: 621 → 582 LOC. * refactor(player-bar): H7 — extract PlayerVolume + PlayerOverflowMenu + 2 hooks PlayerVolume.tsx is the reusable volume button + slider combo, used in three layouts (inline, full menu, volume-only menu) — `inputId` / `sectionModifier` / `wrapModifier` props handle the variants without class duplication. PlayerOverflowMenu.tsx is the portaled Ellipsis-button menu that hosts EQ / mini-player buttons + a PlayerVolume instance. useFloatingPlayerBar owns the docked/floating layout computation (ResizeObserver on sidebar + queue panel). useUtilityOverflowMenu owns the overflow detection, menu open/mode state, close-on-outside-click / Escape, position recompute on resize/scroll, and the wheel-menu timer. PlayerBar.tsx: 582 → 354 LOC.
This commit is contained in:
committed by
GitHub
parent
383bbbd75f
commit
988806e6b1
+108
-556
@@ -1,13 +1,11 @@
|
||||
import { star, unstar, setRating } from '../api/subsonicStarRating';
|
||||
import { star, unstar } from '../api/subsonicStarRating';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { 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,
|
||||
SlidersVertical, X,
|
||||
PictureInPicture2, Ellipsis,
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -30,44 +28,15 @@ 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} />;
|
||||
});
|
||||
import { formatTime } from '../utils/playerBarHelpers';
|
||||
import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock';
|
||||
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
|
||||
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
|
||||
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
|
||||
import { PlayerVolume } from './playerBar/PlayerVolume';
|
||||
import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu';
|
||||
import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar';
|
||||
import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu';
|
||||
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -113,102 +82,24 @@ export default function PlayerBar() {
|
||||
})));
|
||||
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 floatingStyle = useFloatingPlayerBar(playerBarRef, floatingPlayerBar);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
utilityOverflow,
|
||||
utilityMenuOpen,
|
||||
setUtilityMenuOpen,
|
||||
utilityMenuMode,
|
||||
setUtilityMenuMode,
|
||||
utilityMenuStyle,
|
||||
utilityMenuRef,
|
||||
utilityBtnRef,
|
||||
volumeWheelMenuTimerRef,
|
||||
suppressOverflowTooltip,
|
||||
setSuppressOverflowTooltip,
|
||||
} = useUtilityOverflowMenu(playerBarRef, floatingPlayerBar);
|
||||
|
||||
useEffect(() => {
|
||||
const onToggleEqualizer = () => setEqOpen(v => !v);
|
||||
@@ -216,35 +107,6 @@ export default function PlayerBar() {
|
||||
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);
|
||||
@@ -332,245 +194,59 @@ export default function PlayerBar() {
|
||||
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}`)}
|
||||
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
const album: SubsonicAlbum = {
|
||||
id: currentTrack.albumId,
|
||||
name: currentTrack.album,
|
||||
artist: currentTrack.artist,
|
||||
artistId: currentTrack.artistId ?? '',
|
||||
coverArt: currentTrack.coverArt,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
<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>
|
||||
<PlayerTrackInfo
|
||||
currentTrack={currentTrack}
|
||||
currentRadio={currentRadio}
|
||||
isRadio={isRadio}
|
||||
radioMeta={radioMeta}
|
||||
radioCoverSrc={radioCoverSrc}
|
||||
radioCoverKey={radioCoverKey}
|
||||
coverSrc={coverSrc}
|
||||
coverKey={coverKey}
|
||||
displayCoverArt={displayCoverArt}
|
||||
displayTitle={displayTitle}
|
||||
displayArtist={displayArtist}
|
||||
showPreviewMeta={showPreviewMeta}
|
||||
previewingTrack={previewingTrack}
|
||||
isStarred={isStarred}
|
||||
toggleStar={toggleStar}
|
||||
lastfmSessionKey={lastfmSessionKey}
|
||||
lastfmLoved={lastfmLoved}
|
||||
toggleLastfmLove={toggleLastfmLove}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
setUserRatingOverride={setUserRatingOverride}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
navigate={navigate}
|
||||
openContextMenu={openContextMenu}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<PlayerTransportControls
|
||||
isPlaying={isPlaying}
|
||||
isRadio={isRadio}
|
||||
isPreviewing={isPreviewing}
|
||||
stop={stop}
|
||||
previous={previous}
|
||||
next={next}
|
||||
toggleRepeat={toggleRepeat}
|
||||
repeatMode={repeatMode}
|
||||
playPauseBind={playPauseBind}
|
||||
scheduleRemaining={scheduleRemaining}
|
||||
transportAnchorRef={transportAnchorRef}
|
||||
playSlotRef={playSlotRef}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<PlayerSeekbarSection
|
||||
isRadio={isRadio}
|
||||
radioMeta={radioMeta}
|
||||
trackId={currentTrack?.id}
|
||||
duration={duration}
|
||||
localShowRemaining={localShowRemaining}
|
||||
setLocalShowRemaining={setLocalShowRemaining}
|
||||
disableWaveformCanvas={perfFlags.disableWaveformCanvas}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{utilityOverflow ? (
|
||||
<div className="player-overflow-wrap">
|
||||
@@ -595,7 +271,6 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* EQ Button */}
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||
onClick={() => setEqOpen(v => !v)}
|
||||
@@ -605,7 +280,6 @@ export default function PlayerBar() {
|
||||
<SlidersVertical size={15} />
|
||||
</button>
|
||||
|
||||
{/* Mini Player */}
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => invoke('open_mini_player').catch(() => {})}
|
||||
@@ -615,161 +289,39 @@ export default function PlayerBar() {
|
||||
<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>
|
||||
<PlayerVolume
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
premuteVolumeRef={premuteVolumeRef}
|
||||
showVolPct={showVolPct}
|
||||
setShowVolPct={setShowVolPct}
|
||||
handleVolume={handleVolume}
|
||||
handleVolumeWheel={handleVolumeWheel}
|
||||
volumeStyle={volumeStyle}
|
||||
inputId="player-volume"
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
{utilityMenuOpen && (
|
||||
<PlayerOverflowMenu
|
||||
utilityMenuRef={utilityMenuRef}
|
||||
utilityMenuStyle={utilityMenuStyle}
|
||||
utilityMenuMode={utilityMenuMode}
|
||||
eqOpen={eqOpen}
|
||||
setEqOpen={setEqOpen}
|
||||
closeMenu={() => setUtilityMenuOpen(false)}
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
premuteVolumeRef={premuteVolumeRef}
|
||||
showVolPct={showVolPct}
|
||||
setShowVolPct={setShowVolPct}
|
||||
handleVolume={handleVolume}
|
||||
handleVolumeWheel={handleVolumeWheel}
|
||||
volumeStyle={volumeStyle}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTime } from '../../utils/playerBarHelpers';
|
||||
|
||||
/** Renders the playback clock without ever causing PlayerBar to re-render.
|
||||
* Updates the DOM directly via an imperative store subscription. */
|
||||
export 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. */
|
||||
export 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} />;
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PictureInPicture2, SlidersVertical } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { PlayerVolume } from './PlayerVolume';
|
||||
|
||||
interface Props {
|
||||
utilityMenuRef: React.RefObject<HTMLDivElement | null>;
|
||||
utilityMenuStyle: React.CSSProperties;
|
||||
utilityMenuMode: 'full' | 'volume';
|
||||
eqOpen: boolean;
|
||||
setEqOpen: (updater: boolean | ((v: boolean) => boolean)) => void;
|
||||
closeMenu: () => void;
|
||||
volume: number;
|
||||
setVolume: (v: number) => void;
|
||||
premuteVolumeRef: React.MutableRefObject<number>;
|
||||
showVolPct: boolean;
|
||||
setShowVolPct: (v: boolean) => void;
|
||||
handleVolume: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleVolumeWheel: (e: React.WheelEvent<HTMLElement>) => void;
|
||||
volumeStyle: React.CSSProperties;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function PlayerOverflowMenu({
|
||||
utilityMenuRef, utilityMenuStyle, utilityMenuMode,
|
||||
eqOpen, setEqOpen, closeMenu,
|
||||
volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct,
|
||||
handleVolume, handleVolumeWheel, volumeStyle, t,
|
||||
}: Props) {
|
||||
return 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);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<SlidersVertical size={14} />
|
||||
{t('player.equalizer')}
|
||||
</button>
|
||||
<button
|
||||
className="player-overflow-menu-btn"
|
||||
onClick={() => {
|
||||
invoke('open_mini_player').catch(() => {});
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<PictureInPicture2 size={14} />
|
||||
{t('player.miniPlayer')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<PlayerVolume
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
premuteVolumeRef={premuteVolumeRef}
|
||||
showVolPct={showVolPct}
|
||||
setShowVolPct={setShowVolPct}
|
||||
handleVolume={handleVolume}
|
||||
handleVolumeWheel={handleVolumeWheel}
|
||||
volumeStyle={volumeStyle}
|
||||
inputId={utilityMenuMode === 'full' ? 'player-volume-overflow' : 'player-volume-overflow-wheel'}
|
||||
sectionModifier="menu"
|
||||
wrapModifier={utilityMenuMode === 'volume' ? 'menu-only' : undefined}
|
||||
t={t}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { formatTime } from '../../utils/playerBarHelpers';
|
||||
import WaveformSeek from '../WaveformSeek';
|
||||
import { PlaybackTime, RemainingTime } from './PlaybackClock';
|
||||
|
||||
interface Props {
|
||||
isRadio: boolean;
|
||||
radioMeta: RadioMetadata;
|
||||
trackId: string | undefined;
|
||||
duration: number;
|
||||
localShowRemaining: boolean;
|
||||
setLocalShowRemaining: (v: boolean) => void;
|
||||
disableWaveformCanvas: boolean;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function PlayerSeekbarSection({
|
||||
isRadio, radioMeta, trackId, duration, localShowRemaining, setLocalShowRemaining,
|
||||
disableWaveformCanvas, t,
|
||||
}: Props) {
|
||||
return (
|
||||
<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">
|
||||
{disableWaveformCanvas
|
||||
? <div className="radio-progress-bar" aria-hidden />
|
||||
: <WaveformSeek trackId={trackId} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { setRating } from '../../api/subsonicStarRating';
|
||||
import type { InternetRadioStation, SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import type { PlayerState, Track } from '../../store/playerStoreTypes';
|
||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||
import type { PreviewingTrack } from '../../store/previewStore';
|
||||
import CachedImage from '../CachedImage';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import MarqueeText from '../MarqueeText';
|
||||
import StarRating from '../StarRating';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
isRadio: boolean;
|
||||
radioMeta: RadioMetadata;
|
||||
radioCoverSrc: string;
|
||||
radioCoverKey: string;
|
||||
coverSrc: string;
|
||||
coverKey: string;
|
||||
displayCoverArt: string | undefined;
|
||||
displayTitle: string;
|
||||
displayArtist: string;
|
||||
showPreviewMeta: boolean;
|
||||
previewingTrack: PreviewingTrack | null;
|
||||
isStarred: boolean;
|
||||
toggleStar: () => void;
|
||||
lastfmSessionKey: string | null;
|
||||
lastfmLoved: boolean;
|
||||
toggleLastfmLove: () => void;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setUserRatingOverride: (id: string, r: number) => void;
|
||||
toggleFullscreen: () => void;
|
||||
navigate: (to: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function PlayerTrackInfo({
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
|
||||
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist,
|
||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
||||
navigate, openContextMenu, t,
|
||||
}: Props) {
|
||||
return (
|
||||
<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}`)}
|
||||
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
const album: SubsonicAlbum = {
|
||||
id: currentTrack.albumId!,
|
||||
name: currentTrack.album,
|
||||
artist: currentTrack.artist,
|
||||
artistId: currentTrack.artistId ?? '',
|
||||
coverArt: currentTrack.coverArt,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
};
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import PlaybackScheduleBadge from '../PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '../../hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '../../utils/playbackScheduleFormat';
|
||||
|
||||
type RepeatMode = PlayerState['repeatMode'];
|
||||
type PlayPauseBind = ReturnType<typeof usePlaybackDelayPress>['playPauseBind'];
|
||||
type ScheduleRemaining = ReturnType<typeof usePlaybackScheduleRemaining>;
|
||||
|
||||
interface Props {
|
||||
isPlaying: boolean;
|
||||
isRadio: boolean;
|
||||
isPreviewing: boolean;
|
||||
stop: () => void;
|
||||
previous: () => void;
|
||||
next: () => void;
|
||||
toggleRepeat: () => void;
|
||||
repeatMode: RepeatMode;
|
||||
playPauseBind: PlayPauseBind;
|
||||
scheduleRemaining: ScheduleRemaining;
|
||||
transportAnchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
playSlotRef: React.RefObject<HTMLSpanElement | null>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function PlayerTransportControls({
|
||||
isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode,
|
||||
playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t,
|
||||
}: Props) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
interface Props {
|
||||
volume: number;
|
||||
setVolume: (v: number) => void;
|
||||
premuteVolumeRef: React.MutableRefObject<number>;
|
||||
showVolPct: boolean;
|
||||
setShowVolPct: (v: boolean) => void;
|
||||
handleVolume: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleVolumeWheel: (e: React.WheelEvent<HTMLElement>) => void;
|
||||
volumeStyle: React.CSSProperties;
|
||||
inputId: string;
|
||||
/** 'menu' adds the --menu modifier to the outer section (used inside the
|
||||
* overflow menu so the layout adapts). Defaults to no modifier. */
|
||||
sectionModifier?: 'menu';
|
||||
/** 'menu-only' adds the --menu-only modifier to the slider wrap, widening
|
||||
* it when the menu is in volume-only mode. */
|
||||
wrapModifier?: 'menu-only';
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function PlayerVolume({
|
||||
volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct,
|
||||
handleVolume, handleVolumeWheel, volumeStyle, inputId,
|
||||
sectionModifier, wrapModifier, t,
|
||||
}: Props) {
|
||||
const sectionClass = `player-volume-section${sectionModifier ? ` player-volume-section--${sectionModifier}` : ''}`;
|
||||
const wrapClass = `player-volume-slider-wrap${wrapModifier ? ` player-volume-slider-wrap--${wrapModifier}` : ''}`;
|
||||
return (
|
||||
<div className={sectionClass}>
|
||||
<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={wrapClass} onWheel={handleVolumeWheel}>
|
||||
{showVolPct && (
|
||||
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
id={inputId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
/** Computes the floating player-bar position based on the current sidebar +
|
||||
* queue panel widths. Returns an inline-style object (left/right/width); only
|
||||
* active when `floatingPlayerBar` is true. Uses a ResizeObserver on both
|
||||
* containers so the bar slides while the sidebar or queue is resized. */
|
||||
export function useFloatingPlayerBar(
|
||||
_playerBarRef: React.RefObject<HTMLElement | null>,
|
||||
floatingPlayerBar: boolean,
|
||||
): React.CSSProperties {
|
||||
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
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]);
|
||||
|
||||
return floatingStyle;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Wires the utility-overflow Ellipsis button + its portaled menu:
|
||||
* - watches the player-bar width via ResizeObserver and flips `utilityOverflow`
|
||||
* when it crosses the threshold (980 px floating / 1140 px docked)
|
||||
* - owns the menu open state and the 'full' vs. 'volume' display mode
|
||||
* - closes the menu on outside click or Escape
|
||||
* - re-positions the menu (fixed, above the trigger) on resize/scroll
|
||||
* - exposes a volumeWheelMenuTimerRef so the volume-wheel auto-hide handler
|
||||
* can reuse the same timer. */
|
||||
export function useUtilityOverflowMenu(
|
||||
playerBarRef: React.RefObject<HTMLElement | null>,
|
||||
floatingPlayerBar: boolean,
|
||||
) {
|
||||
const [utilityOverflow, setUtilityOverflow] = useState(false);
|
||||
const [utilityMenuOpen, setUtilityMenuOpen] = useState(false);
|
||||
const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full');
|
||||
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
|
||||
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
|
||||
const utilityMenuRef = useRef<HTMLDivElement>(null);
|
||||
const utilityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const volumeWheelMenuTimerRef = useRef<number | null>(null);
|
||||
|
||||
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, playerBarRef]);
|
||||
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
return {
|
||||
utilityOverflow,
|
||||
utilityMenuOpen,
|
||||
setUtilityMenuOpen,
|
||||
utilityMenuMode,
|
||||
setUtilityMenuMode,
|
||||
utilityMenuStyle,
|
||||
utilityMenuRef,
|
||||
utilityBtnRef,
|
||||
volumeWheelMenuTimerRef,
|
||||
suppressOverflowTooltip,
|
||||
setSuppressOverflowTooltip,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export 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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user