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:
Frank Stellmacher
2026-05-13 23:24:57 +02:00
committed by GitHub
parent 383bbbd75f
commit 988806e6b1
10 changed files with 826 additions and 556 deletions
@@ -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>
);
}
+71
View File
@@ -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>
);
}