refactor(playback): co-locate player bar UI (PlayerBar + playerBar/* + PlaybackBufferingOverlay) into features/playback/components

This commit is contained in:
Psychotoxical
2026-06-30 18:37:06 +02:00
parent 5cb5cf1d24
commit 5c3a135040
13 changed files with 40 additions and 40 deletions
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { PlaybackBufferingOverlay } from '@/features/playback/components/PlaybackBufferingOverlay';
describe('PlaybackBufferingOverlay', () => {
it('exposes buffering status for assistive tech', () => {
renderWithProviders(<PlaybackBufferingOverlay />);
expect(
screen.getByRole('status', { name: 'Loading track from server' }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
import { Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/** Shown over cover art while an HTTP stream is still opening / buffering. */
export function PlaybackBufferingOverlay() {
const { t } = useTranslation();
return (
<div
className="playback-buffering-overlay"
role="status"
aria-live="polite"
aria-label={t('player.bufferingStream')}
>
<Clock size={26} strokeWidth={2} className="playback-buffering-overlay__icon" aria-hidden />
</div>
);
}
@@ -0,0 +1,178 @@
/**
* `PlayerBar` characterization (Phase F5b).
*
* Asserts on the public control wiring (play/pause/next/prev/repeat/stop)
* and the no-track empty state. Visual / hover / overflow logic lives in
* the manual smoke list — jsdom doesn't compute layout.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
pingWithCredentials: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
pingWithCredentialsForProfile: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
import PlayerBar from '@/features/playback/components/PlayerBar';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
vi.useFakeTimers();
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
describe('PlayerBar — render', () => {
it('renders the player region with the labelled landmark', () => {
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Music Player')).toBeInTheDocument();
});
it('exposes Previous Track / Play / Next Track / Repeat / Stop controls when a track is loaded', () => {
const track = makeTrack();
usePlayerStore.setState({ currentTrack: track, isPlaying: false });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Previous Track')).toBeInTheDocument();
expect(getByLabelText('Play')).toBeInTheDocument();
expect(getByLabelText('Next Track')).toBeInTheDocument();
expect(getByLabelText('Repeat')).toBeInTheDocument();
expect(getByLabelText('Stop')).toBeInTheDocument();
});
it('the middle control reads "Pause" while playback is active', () => {
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Pause')).toBeInTheDocument();
});
});
describe('PlayerBar — control wiring', () => {
it('clicking the Play/Pause button calls togglePlay', () => {
const track = makeTrack();
usePlayerStore.setState({ currentTrack: track, isPlaying: false });
const toggleSpy = vi.spyOn(usePlayerStore.getState(), 'togglePlay');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Play'));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 10 }); // > 3 s → restart current
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
const t1 = makeTrack({ id: 'a' });
const t2 = makeTrack({ id: 'b' });
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles through off → all → one', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText, rerender } = renderWithProviders(<PlayerBar />);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
rerender(<PlayerBar />);
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('one');
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
});
describe('PlayerBar — current track context menu', () => {
it('right-clicking the track name opens a song-scoped menu for the current track (#1116)', () => {
const track = makeTrack({ id: 'cur', albumId: 'alb-1' });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const spy = vi.spyOn(usePlayerStore.getState(), 'openContextMenu');
const { container } = renderWithProviders(<PlayerBar />);
const trackName = container.querySelector('.player-track-name');
expect(trackName).not.toBeNull();
fireEvent.contextMenu(trackName!);
expect(spy).toHaveBeenCalledTimes(1);
const [, , item, type, , , , , pin] = spy.mock.calls[0];
expect(item).toBe(track);
expect(type).toBe('song');
expect(pin).toBe(true);
});
});
describe('PlayerBar — empty state (no current track)', () => {
it('still renders the region landmark when no track is loaded', () => {
usePlayerStore.setState({ currentTrack: null, isPlaying: false });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Music Player')).toBeInTheDocument();
});
});
@@ -0,0 +1,349 @@
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { coverArtIdFromRadio } from '@/cover/ids';
import { resolvePlaybackTrackCoverArtId } from '@/cover/resolveCoverArtId';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
SlidersVertical, X,
PictureInPicture2, Ellipsis,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import { Equalizer } from '@/features/equalizer';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
import { useRadioMetadata } from '@/features/radio';
import { useRadioMprisSync } from '@/features/radio';
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
import PlaybackDelayModal from '@/components/PlaybackDelayModal';
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { coerceOpenArtistRefs } from '@/lib/api/openArtistRefs';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { PlayerTrackInfo } from '@/features/playback/components/playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from '@/features/playback/components/playerBar/PlayerTransportControls';
import { PlayerSeekbarSection } from '@/features/playback/components/playerBar/PlayerSeekbarSection';
import { PlayerPlaybackRate } from '@/features/playback/components/playerBar/PlayerPlaybackRate';
import { PlayerVolume } from '@/features/playback/components/playerBar/PlayerVolume';
import { PlayerOverflowMenu } from '@/features/playback/components/playerBar/PlayerOverflowMenu';
import { useFloatingPlayerBar } from '@/hooks/useFloatingPlayerBar';
import { useUtilityOverflowMenu } from '@/hooks/useUtilityOverflowMenu';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
export default function PlayerBar() {
const { t } = useTranslation();
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
const [eqOpen, setEqOpen] = useState(false);
const [showVolPct, setShowVolPct] = useState(false);
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
const premuteVolumeRef = useRef(1);
// currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update.
const {
currentTrack, currentRadio, isPlaying, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
networkLoved, toggleNetworkLove,
starredOverrides,
userRatingOverrides,
openContextMenu,
} = 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,
networkLoved: s.networkLoved,
toggleNetworkLove: s.toggleNetworkLove,
isQueueVisible: s.isQueueVisible,
toggleQueue: s.toggleQueue,
starredOverrides: s.starredOverrides,
userRatingOverrides: s.userRatingOverrides,
openContextMenu: s.openContextMenu,
})));
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const playerBarRef = useRef<HTMLElement>(null);
const perfFlags = usePerfProbeFlags();
const floatingStyle = useFloatingPlayerBar(playerBarRef, floatingPlayerBar);
const {
utilityOverflow,
utilityMenuOpen,
setUtilityMenuOpen,
utilityMenuMode,
setUtilityMenuMode,
utilityMenuStyle,
utilityMenuRef,
utilityBtnRef,
volumeWheelMenuTimerRef,
suppressOverflowTooltip,
setSuppressOverflowTooltip,
} = useUtilityOverflowMenu(playerBarRef, floatingPlayerBar);
useEffect(() => {
const onToggleEqualizer = () => setEqOpen(v => !v);
window.addEventListener('psy:toggle-equalizer', onToggleEqualizer);
return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer);
}, []);
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);
// Mirror resolved radio track metadata to the OS media controls (issue #816).
// PlayerBar is the single always-mounted consumer, so push from here only.
useRadioMprisSync(radioMeta, currentRadio);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
// 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 displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
const displayArtistRefs = !showPreviewMeta && currentTrack && coerceOpenArtistRefs(currentTrack.artists).length > 0
? resolveTrackArtistRefs(currentTrack)
: undefined;
const coverArtId = showPreviewMeta
? previewingTrack?.coverArt
: resolvePlaybackTrackCoverArtId(currentTrack);
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, setSuppressOverflowTooltip, setUtilityMenuMode, setUtilityMenuOpen, volumeWheelMenuTimerRef]);
const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${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')}
>
<PlayerTrackInfo
currentTrack={currentTrack}
currentRadio={currentRadio}
isRadio={isRadio}
radioMeta={radioMeta}
radioCoverArtId={radioCoverArtId}
coverArtId={coverArtId}
displayTitle={displayTitle}
displayArtist={displayArtist}
displayArtistRefs={displayArtistRefs}
showPreviewMeta={showPreviewMeta}
previewingTrack={previewingTrack}
isStarred={isStarred}
toggleStar={toggleStar}
enrichmentPrimaryId={enrichmentPrimaryId}
networkLoved={networkLoved}
toggleNetworkLove={toggleNetworkLove}
userRatingOverrides={userRatingOverrides}
toggleFullscreen={toggleFullscreen}
navigate={navigatePlaybackLibrary}
openContextMenu={openContextMenu}
t={t}
/>
<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}
/>
<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">
<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>
) : (
<>
{isLayoutVisible('playbackRate') && (
<PlayerPlaybackRate t={t} />
)}
{isLayoutVisible('equalizer') && (
<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>
)}
{isLayoutVisible('miniPlayer') && (
<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>
)}
<PlayerVolume
volume={volume}
setVolume={setVolume}
premuteVolumeRef={premuteVolumeRef}
showVolPct={showVolPct}
setShowVolPct={setShowVolPct}
handleVolume={handleVolume}
handleVolumeWheel={handleVolumeWheel}
volumeStyle={volumeStyle}
inputId="player-volume"
t={t}
/>
</>
)}
{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 */}
{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;
}
@@ -0,0 +1,59 @@
import { memo, useEffect, useRef } from 'react';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
import {
formatPlaybarClock,
formatPlaybarToggleClock,
formatTrackTime,
} from '@/lib/format/formatDuration';
/** 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,
minuteFieldWidth,
}: {
className?: string;
minuteFieldWidth?: number;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const format = (seconds: number) =>
minuteFieldWidth != null
? formatPlaybarClock(seconds, minuteFieldWidth)
: formatTrackTime(seconds);
if (spanRef.current) {
spanRef.current.textContent = format(getPlaybackProgressSnapshot().currentTime);
}
return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = format(state.currentTime);
});
}, [minuteFieldWidth]);
return <span className={className} ref={spanRef} />;
});
/** Remaining/duration toggle clock — fixed-length string, imperative updates. */
export const ToggleClock = memo(function ToggleClock({
duration,
minuteFieldWidth,
remaining,
className,
}: {
duration: number;
minuteFieldWidth: number;
remaining: boolean;
className?: string;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const update = () => {
if (!spanRef.current) return;
const seconds = remaining
? Math.max(0, duration - getPlaybackProgressSnapshot().currentTime)
: duration;
spanRef.current.textContent = formatPlaybarToggleClock(seconds, minuteFieldWidth, remaining);
};
update();
return remaining ? subscribePlaybackProgress(update) : undefined;
}, [duration, minuteFieldWidth, remaining]);
return <span className={className} ref={spanRef} />;
});
@@ -0,0 +1,98 @@
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 '@/features/playback/components/playerBar/PlayerVolume';
import { PlayerPlaybackRateMenuSection } from '@/features/playback/components/playerBar/PlayerPlaybackRate';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
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) {
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
const showEqualizer = isLayoutVisible('equalizer');
const showMiniPlayer = isLayoutVisible('miniPlayer');
const showPlaybackRate = isLayoutVisible('playbackRate');
return createPortal(
<div
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
ref={utilityMenuRef}
style={utilityMenuStyle}
onWheel={handleVolumeWheel}
>
{utilityMenuMode === 'full' && (showEqualizer || showMiniPlayer) && (
<div className="player-overflow-menu-row">
{showEqualizer && (
<button
className={`player-overflow-menu-btn${eqOpen ? ' active' : ''}`}
onClick={() => {
setEqOpen(v => !v);
closeMenu();
}}
>
<SlidersVertical size={14} />
{t('player.equalizer')}
</button>
)}
{showMiniPlayer && (
<button
className="player-overflow-menu-btn"
onClick={() => {
invoke('open_mini_player').catch(() => {});
closeMenu();
}}
>
<PictureInPicture2 size={14} />
{t('player.miniPlayer')}
</button>
)}
</div>
)}
{utilityMenuMode === 'full' && showPlaybackRate && (
<PlayerPlaybackRateMenuSection t={t} />
)}
<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,94 @@
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import { PlaybackRateControls } from '@/features/settings';
import { usePlaybackRateStore } from '@/features/playback/store/playbackRateStore';
import { useOrbitStore } from '@/features/orbit';
import {
clampPlaybackPitch,
clampPlaybackSpeed,
derivedVarispeedSemitones,
formatSpeedLabel,
isPlaybackRateApplied,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
} from '@/features/playback/utils/audio/playbackRateHelpers';
import { isOrbitPlaybackSyncActive } from '@/features/orbit';
import { usePlayerBarAnchoredPopover } from '@/hooks/usePlayerBarAnchoredPopover';
const POPOVER_WIDTH = 320;
interface Props {
t: TFunction;
}
export function PlayerPlaybackRateMenuSection({ t }: Props) {
const enabled = usePlaybackRateStore(s => s.enabled);
if (!enabled) return null;
return (
<div className="player-playback-rate-menu-section">
<PlaybackRateControls t={t} showEnable={false} />
</div>
);
}
export function PlayerPlaybackRate({ t }: Props) {
const enabled = usePlaybackRateStore(s => s.enabled);
const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const fineStep = usePlaybackRateStore(s => s.fineStep);
const setSpeed = usePlaybackRateStore(s => s.setSpeed);
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const { open, setOpen, popStyle, btnRef, popRef } = usePlayerBarAnchoredPopover(POPOVER_WIDTH);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const handleWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!enabled) return;
e.preventDefault();
if (strategy === 'varispeed_semitones') {
const pitchStep = playbackPitchStep(fineStep);
const step = e.deltaY > 0 ? -pitchStep : pitchStep;
const st = clampPlaybackPitch(derivedVarispeedSemitones(speed) + step);
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(st)));
return;
}
const speedStep = playbackSpeedStep(fineStep);
const delta = e.deltaY > 0 ? -speedStep : speedStep;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [enabled, strategy, speed, fineStep, setSpeed]);
if (!enabled) return null;
return (
<>
<button
ref={btnRef}
type="button"
className={`player-btn player-btn-sm player-playback-rate-btn${open ? ' active' : ''}${effectActive ? ' player-playback-rate-btn--live' : ''}`}
onClick={() => setOpen(v => !v)}
onWheel={handleWheel}
aria-label={t('player.playbackRate')}
aria-expanded={open}
data-tooltip={t('player.playbackRate')}
>
{formatSpeedLabel(speed)}
</button>
{open && createPortal(
<div
ref={popRef}
className="player-playback-rate-popover"
style={popStyle}
>
<PlaybackRateControls t={t} showEnable={false} />
</div>,
document.body,
)}
</>
);
}
@@ -0,0 +1,86 @@
import { ArrowLeftRight } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { RadioMetadata } from '@/features/radio';
import { useThemeStore } from '@/store/themeStore';
import { formatTrackTime, playbarMinuteFieldWidth } from '@/lib/format/formatDuration';
import { WaveformSeek } from '@/features/waveform';
import { PlaybackTime, ToggleClock } from '@/features/playback/components/playerBar/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) {
const minuteFieldWidth = playbarMinuteFieldWidth(duration);
const playbarClockStyle = {
'--playbar-clock-body-ch': minuteFieldWidth + 3,
'--playbar-clock-signed-ch': minuteFieldWidth + 4,
} as React.CSSProperties;
return (
<div className="player-waveform-section" style={playbarClockStyle}>
{isRadio ? (
<>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
<>
<span className="player-time">{formatTrackTime(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">{formatTrackTime(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" minuteFieldWidth={minuteFieldWidth} />
<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')}
>
<ToggleClock
className="player-time-toggle__label"
duration={duration}
minuteFieldWidth={minuteFieldWidth}
remaining={localShowRemaining}
/>
<ArrowLeftRight className="player-time-toggle__icon" size={10} aria-hidden />
</span>
</>
)}
</div>
);
}
@@ -0,0 +1,213 @@
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
import type { TFunction } from 'i18next';
import { queueSongRating } from '@/features/playback/store/pendingStarSync';
import type { InternetRadioStation, SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import type { RadioMetadata } from '@/features/radio';
import type { PreviewingTrack } from '@/features/playback/store/previewStore';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import MarqueeText from '@/ui/MarqueeText';
import { OpenArtistRefInline } from '@/features/artist';
import StarRating from '@/ui/StarRating';
import { PlaybackBufferingOverlay } from '@/features/playback/components/PlaybackBufferingOverlay';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useEnrichmentPrimaryLabel } from '@/hooks/useEnrichmentPrimaryLabel';
import { useEnrichmentPrimaryIcon } from '@/hooks/useEnrichmentPrimaryIcon';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
interface Props {
currentTrack: Track | null;
currentRadio: InternetRadioStation | null;
isRadio: boolean;
radioMeta: RadioMetadata;
radioCoverArtId?: string;
coverArtId?: string;
displayTitle: string;
displayArtist: string;
/** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
displayArtistRefs?: SubsonicOpenArtistRef[];
showPreviewMeta: boolean;
previewingTrack: PreviewingTrack | null;
isStarred: boolean;
toggleStar: () => void;
enrichmentPrimaryId: string | null;
networkLoved: boolean;
toggleNetworkLove: () => void;
userRatingOverrides: Record<string, number>;
toggleFullscreen: () => void;
navigate: (to: string) => void | Promise<void>;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
export function PlayerTrackInfo({
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
coverArtId, displayTitle, displayArtist, displayArtistRefs,
showPreviewMeta, previewingTrack, isStarred, toggleStar,
enrichmentPrimaryId, networkLoved, toggleNetworkLove,
userRatingOverrides, toggleFullscreen,
navigate, openContextMenu, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
const networkIcon = useEnrichmentPrimaryIcon();
const playbackCoverRef = usePlaybackTrackCoverRef(
showPreviewMeta ? null : currentTrack ?? undefined,
);
const previewCoverRef = useAlbumCoverRef(
showPreviewMeta ? coverArtId : null,
showPreviewMeta ? coverArtId : null,
undefined,
showPreviewMeta ? { libraryResolve: false } : undefined,
);
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
const offlineBrowseActive = useOfflineBrowseContext().active;
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
return (
<div className="player-track-info">
<div
className={`player-album-art-wrap${showBufferingOverlay && !isRadio && !showPreviewMeta ? ' playback-buffering' : ''}${currentTrack && !isRadio && !showPreviewMeta ? ' clickable' : ''}`}
onClick={() => !isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()}
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
>
{isRadio ? (
radioCoverArtId && currentRadio ? (
<CoverArtImage
className="player-album-art"
coverRef={albumCoverRef(radioCoverArtId, radioCoverArtId)}
displayCssPx={128}
surface="sparse"
alt={currentRadio?.name ?? ''}
/>
) : (
<div className="player-album-art-placeholder">
<Cast size={20} />
</div>
)
) : !isRadio && activeCoverRef ? (
<CoverArtImage
className="player-album-art"
coverRef={activeCoverRef}
displayCssPx={128}
surface="sparse"
ensurePriority="high"
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>
)}
{showBufferingOverlay && !isRadio && !showPreviewMeta && (
<PlaybackBufferingOverlay />
)}
</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
? (e) => {
e.preventDefault();
// The player bar represents the current song, so its menu is
// song-scoped (e.g. "Add to playlist" adds this track, not the
// whole album). pinToPlaybackServer: the track plays from the
// playback server, which may differ from the active one.
openContextMenu(e.clientX, e.clientY, currentTrack, 'song', undefined, undefined, undefined, undefined, true);
}
: undefined}
/>
{!isRadio && displayArtistRefs && displayArtistRefs.length > 0 ? (
<div className="marquee-wrap player-track-artist">
<OpenArtistRefInline
refs={displayArtistRefs}
fallbackName={displayArtist}
onGoArtist={id => navigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="player-artist-link"
/>
</div>
) : (
<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 && isLayoutVisible('starRating') && playerPolicy.canRate && (
<StarRating
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
onChange={r => queueSongRating(currentTrack.id, r)}
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 && isLayoutVisible('favorite') && playerPolicy.canFavorite && (
<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 && enrichmentPrimaryId !== null && isLayoutVisible('lastfmLove') && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleNetworkLove}
aria-label={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
style={{ color: networkLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
>
{renderPresetIcon(networkIcon ?? 'lastfm', 15)}
</button>
)}
</div>
);
}
@@ -0,0 +1,141 @@
import React from 'react';
import { Blend, 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 '@/features/playback/store/playerStoreTypes';
import { useAutodjTransitionUi } from '@/features/playback/store/autodjTransitionUi';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import PlaybackScheduleBadge from '@/components/PlaybackScheduleBadge';
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
import { usePlaybackScheduleRemaining } from '@/utils/format/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) {
const autodjPhase = useAutodjTransitionUi(s => s.phase);
const showAutodjTransition =
isPlaying && !isPreviewing && scheduleRemaining == null && autodjPhase === 'mixing';
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' : '',
showAutodjTransition ? 'is-autodj-transition' : '',
].filter(Boolean).join(' ')}
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')
: showAutodjTransition
? t('player.autoDjMixing')
: isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPreviewing
? t('playlists.previewStop')
: showAutodjTransition
? t('player.autoDjMixing')
: 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} />
) : showAutodjTransition ? (
<Blend size={22} className="player-btn-autodj-icon" strokeWidth={2.25} />
) : 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')}`}
disabled={isRadio}
style={isRadio
? { opacity: 0.3, pointerEvents: 'none' }
: { color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
);
}
@@ -0,0 +1,72 @@
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={volume === 0 ? t('player.unmute') : t('player.mute')}
data-tooltip={volume === 0 ? t('player.unmute') : t('player.mute')}
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>
);
}