mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(playback): co-locate player bar UI (PlayerBar + playerBar/* + PlaybackBufferingOverlay) into features/playback/components
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnes
|
||||
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/lib/library/trackEnrichment';
|
||||
import { useQueueTrackEnrichment } from '@/features/queue/hooks/useQueueTrackEnrichment';
|
||||
import { QueueLufsTargetMenu } from '@/features/queue/components/QueueLufsTargetMenu';
|
||||
import { PlaybackBufferingOverlay } from '@/components/playback/PlaybackBufferingOverlay';
|
||||
import { PlaybackBufferingOverlay } from '@/features/playback/components/PlaybackBufferingOverlay';
|
||||
import { CoverArtImage } from '@/cover/CoverArtImage';
|
||||
import { OpenArtistRefInline } from '@/features/artist';
|
||||
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
|
||||
Reference in New Issue
Block a user