mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
import { queueSongStar } from '../store/pendingStarSync';
|
||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||
import type { Track } from '../store/playerStoreTypes';
|
||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||
import React, { useState, useCallback, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||
import {
|
||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||
Moon, Sunrise,
|
||
} from 'lucide-react';
|
||
import { usePlayerStore } from '../store/playerStore';
|
||
import { useCachedUrl } from './CachedImage';
|
||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||
import {
|
||
getQueueResolverVersion,
|
||
subscribeQueueResolver,
|
||
} from '../utils/library/queueTrackResolver';
|
||
import LyricsPane from './LyricsPane';
|
||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||
import PlaybackDelayModal from './PlaybackDelayModal';
|
||
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
|
||
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
|
||
|
||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
|
||
|
||
function extractVibrantColor(imageUrl: string): Promise<string> {
|
||
return new Promise(resolve => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 16;
|
||
canvas.height = 16;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) { resolve('0,0,0'); return; }
|
||
ctx.drawImage(img, 0, 0, 16, 16);
|
||
const { data } = ctx.getImageData(0, 0, 16, 16);
|
||
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||
const max = Math.max(r, g, b) / 255;
|
||
const min = Math.min(r, g, b) / 255;
|
||
const l = (max + min) / 2;
|
||
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
|
||
// Prefer saturated pixels in the medium-dark range (l 0.2–0.6)
|
||
const score = s * (1 - Math.abs(l - 0.4));
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestR = r; bestG = g; bestB = b;
|
||
}
|
||
}
|
||
resolve(`${bestR},${bestG},${bestB}`);
|
||
};
|
||
img.onerror = () => resolve('0,0,0');
|
||
img.src = imageUrl;
|
||
});
|
||
}
|
||
|
||
function useAlbumAccentColor(imageUrl: string): string {
|
||
const [color, setColor] = useState('0,0,0');
|
||
useEffect(() => {
|
||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
if (!imageUrl) { setColor('0,0,0'); return; }
|
||
let cancelled = false;
|
||
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
|
||
return () => { cancelled = true; };
|
||
}, [imageUrl]);
|
||
return color;
|
||
}
|
||
|
||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||
|
||
// Stable initial rect so the virtualizer never re-initializes on re-render (an
|
||
// inline literal would be a new ref each render → render loop). Replaced by the
|
||
// real height on first ResizeObserver measure.
|
||
const QUEUE_INITIAL_RECT = { width: 0, height: 600 };
|
||
|
||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||
const { t } = useTranslation();
|
||
const queue = usePlayerStore(s => s.queueItems);
|
||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||
const playTrack = usePlayerStore(s => s.playTrack);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
// Thin-state: the queue is the canonical `QueueItemRef[]`; each row's Track
|
||
// comes from the resolver (cache → placeholder), matching the desktop
|
||
// QueueList. Subscribe once so rows re-render as the resolver cache fills.
|
||
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||
|
||
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
|
||
// mobile too (matches the desktop QueuePanel).
|
||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||
// eslint-disable-next-line react-hooks/incompatible-library
|
||
const rowVirtualizer = useVirtualizer({
|
||
count: queue.length,
|
||
getScrollElement: () => listRef.current,
|
||
estimateSize: () => 56,
|
||
overscan: 10,
|
||
getItemKey: i => `${queue[i].trackId}:${i}`,
|
||
initialRect: QUEUE_INITIAL_RECT,
|
||
});
|
||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||
const totalSize = rowVirtualizer.getTotalSize();
|
||
|
||
// Scroll the active track into view on open. Rows are uniform height, so the
|
||
// virtualizer's estimate lands the centred index accurately.
|
||
useEffect(() => {
|
||
if (queueIndex >= 0 && queue.length > 0) {
|
||
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
return (
|
||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
|
||
<div className="mq-drawer-header">
|
||
<h3>{t('queue.title')}</h3>
|
||
<span className="mq-drawer-count">
|
||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
|
||
</span>
|
||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<div className="mq-drawer-list" ref={listRef}>
|
||
{queue.length === 0 ? (
|
||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||
) : (
|
||
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
|
||
{virtualItems.map(vi => {
|
||
const idx = vi.index;
|
||
const track = resolveQueueTrack(queue[idx]);
|
||
const isActive = idx === queueIndex;
|
||
return (
|
||
<div
|
||
key={vi.key}
|
||
data-index={idx}
|
||
ref={rowVirtualizer.measureElement}
|
||
className={`mq-item${isActive ? ' active' : ''}`}
|
||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||
onClick={() => { playTrack(track, undefined, undefined, undefined, idx); onClose(); }}
|
||
>
|
||
<div className="mq-item-info">
|
||
<div className="mq-item-title">
|
||
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
||
<span className="truncate">{track.title}</span>
|
||
</div>
|
||
<div className="mq-item-artist truncate">{track.artist}</div>
|
||
</div>
|
||
<span className="mq-item-dur">{formatTrackTime(track.duration)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
|
||
|
||
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
|
||
const { t } = useTranslation();
|
||
|
||
return (
|
||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
|
||
<div className="mq-drawer-header">
|
||
<h3>{t('player.lyrics')}</h3>
|
||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<div className="mq-drawer-list">
|
||
<LyricsPane currentTrack={currentTrack} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Mobile Player View ────────────────────────────────────────────────────────
|
||
|
||
export default function MobilePlayerView() {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||
// Lock body scroll while full-screen player is mounted
|
||
useEffect(() => {
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, []);
|
||
|
||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||
const playbackProgress = useSyncExternalStore(
|
||
onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
|
||
getPlaybackProgressSnapshot,
|
||
getPlaybackProgressSnapshot,
|
||
);
|
||
const progress = playbackProgress.progress;
|
||
const currentTime = playbackProgress.currentTime;
|
||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||
const transportAnchorRef = useRef<HTMLDivElement>(null);
|
||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||
const next = usePlayerStore(s => s.next);
|
||
const previous = usePlayerStore(s => s.previous);
|
||
const seek = usePlayerStore(s => s.seek);
|
||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||
|
||
const duration = currentTrack?.duration ?? 0;
|
||
|
||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
|
||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||
|
||
// Dynamic background color extracted from cover art
|
||
const accentColor = useAlbumAccentColor(resolvedCover);
|
||
|
||
// Star / favorite
|
||
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]);
|
||
|
||
// Scrubber touch/mouse drag
|
||
const scrubberRef = useRef<HTMLDivElement>(null);
|
||
const isDragging = useRef(false);
|
||
const pendingSeekRef = useRef<number | null>(null);
|
||
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
|
||
|
||
const setPreviewSeek = useCallback((pct: number) => {
|
||
pendingSeekRef.current = pct;
|
||
setPreviewProgress(pct);
|
||
}, []);
|
||
|
||
const seekFromX = useCallback((clientX: number) => {
|
||
const el = scrubberRef.current;
|
||
if (!el) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||
setPreviewSeek(pct);
|
||
}, [setPreviewSeek]);
|
||
|
||
const onScrubStart = useCallback((clientX: number) => {
|
||
isDragging.current = true;
|
||
seekFromX(clientX);
|
||
}, [seekFromX]);
|
||
|
||
useEffect(() => {
|
||
const onMove = (e: TouchEvent | MouseEvent) => {
|
||
if (!isDragging.current) return;
|
||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||
seekFromX(clientX);
|
||
};
|
||
const onEnd = () => {
|
||
if (!isDragging.current) return;
|
||
isDragging.current = false;
|
||
const pending = pendingSeekRef.current;
|
||
pendingSeekRef.current = null;
|
||
setPreviewProgress(null);
|
||
if (pending !== null) seek(pending);
|
||
};
|
||
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', onEnd);
|
||
window.addEventListener('touchmove', onMove, { passive: true });
|
||
window.addEventListener('touchend', onEnd);
|
||
return () => {
|
||
window.removeEventListener('mousemove', onMove);
|
||
window.removeEventListener('mouseup', onEnd);
|
||
window.removeEventListener('touchmove', onMove);
|
||
window.removeEventListener('touchend', onEnd);
|
||
};
|
||
}, [seekFromX, seek]);
|
||
|
||
useEffect(() => {
|
||
pendingSeekRef.current = null;
|
||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setPreviewProgress(null);
|
||
}, [currentTrack?.id]);
|
||
|
||
// Drawers
|
||
const [showQueue, setShowQueue] = useState(false);
|
||
const [showLyrics, setShowLyrics] = useState(false);
|
||
|
||
// ── Empty state ──
|
||
if (!currentTrack) {
|
||
return (
|
||
<div className="mp-view">
|
||
<div className="mp-header">
|
||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||
<ChevronDown size={28} />
|
||
</button>
|
||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||
<div style={{ width: 44 }} />
|
||
</div>
|
||
<div className="mp-empty">
|
||
<Music size={56} style={{ opacity: 0.25 }} />
|
||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const bgStyle: CSSProperties = {
|
||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||
};
|
||
const effectiveProgress = previewProgress ?? progress;
|
||
const effectiveTime =
|
||
previewProgress !== null && duration > 0
|
||
? previewProgress * duration
|
||
: currentTime;
|
||
|
||
return (
|
||
<div className="mp-view" style={bgStyle}>
|
||
{/* Header */}
|
||
<div className="mp-header">
|
||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||
<ChevronDown size={28} />
|
||
</button>
|
||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||
<div style={{ width: 44 }} />
|
||
</div>
|
||
|
||
{/* Cover Art */}
|
||
<div className="mp-cover-wrap">
|
||
{resolvedCover ? (
|
||
<img src={resolvedCover} alt="" className="mp-cover" />
|
||
) : (
|
||
<div className="mp-cover mp-cover-fallback">
|
||
<Music size={64} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Track Metadata */}
|
||
<div className="mp-meta">
|
||
<div className="mp-meta-text">
|
||
<div className="mp-title truncate">{currentTrack.title}</div>
|
||
<div className="mp-artist truncate">
|
||
{currentTrack.artists && currentTrack.artists.length > 0 ? (
|
||
<OpenArtistRefInline
|
||
refs={currentTrack.artists}
|
||
fallbackName={currentTrack.artist}
|
||
onGoArtist={id => { void navigatePlaybackLibrary(`/artist/${id}`); }}
|
||
as="none"
|
||
linkTag="span"
|
||
linkClassName="mp-artist-link"
|
||
/>
|
||
) : (
|
||
<span
|
||
role={currentTrack.artistId ? 'link' : undefined}
|
||
tabIndex={currentTrack.artistId ? 0 : undefined}
|
||
onClick={() => currentTrack.artistId && void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`)}
|
||
onKeyDown={e => {
|
||
if (!currentTrack.artistId) return;
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`);
|
||
}
|
||
}}
|
||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||
>
|
||
{currentTrack.artist}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{(() => {
|
||
const parts = [
|
||
currentTrack.year,
|
||
currentTrack.genre,
|
||
currentTrack.suffix?.toUpperCase(),
|
||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
|
||
].filter(Boolean);
|
||
return parts.length > 0
|
||
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
|
||
: null;
|
||
})()}
|
||
</div>
|
||
<button
|
||
className={`mp-heart${isStarred ? ' active' : ''}`}
|
||
onClick={toggleStar}
|
||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||
>
|
||
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Scrubber */}
|
||
<div className="mp-scrubber-wrap">
|
||
<div
|
||
className="mp-scrubber"
|
||
ref={scrubberRef}
|
||
onMouseDown={e => onScrubStart(e.clientX)}
|
||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||
>
|
||
<div className="mp-scrubber-bg" />
|
||
<div className="mp-scrubber-fill" style={{ width: `${effectiveProgress * 100}%` }} />
|
||
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
|
||
</div>
|
||
<div className="mp-scrubber-times">
|
||
<span>{formatTrackTime(effectiveTime)}</span>
|
||
<span>-{formatTrackTime(Math.max(0, duration - effectiveTime))}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Transport Controls */}
|
||
<div className="mp-controls" ref={transportAnchorRef}>
|
||
<button
|
||
className="mp-ctrl-btn mp-ctrl-sm"
|
||
onClick={() => shuffleQueue()}
|
||
aria-label={t('queue.shuffle')}
|
||
>
|
||
<Shuffle size={20} />
|
||
</button>
|
||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||
<SkipBack size={28} />
|
||
</button>
|
||
<span className="playback-transport-play-wrap" ref={playSlotRef}>
|
||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
|
||
<button
|
||
className="mp-ctrl-btn mp-ctrl-play"
|
||
type="button"
|
||
{...playPauseBind}
|
||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||
>
|
||
{scheduleRemaining != null ? (
|
||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--mobile`}>
|
||
{scheduleRemaining.mode === 'pause'
|
||
? <Moon size={13} strokeWidth={2.5} />
|
||
: <Sunrise size={13} strokeWidth={2.5} />}
|
||
<span className="player-btn-schedule-time player-btn-schedule-time--mobile">{scheduleRemaining.remaining}</span>
|
||
</span>
|
||
) : isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||
</button>
|
||
</span>
|
||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||
<SkipForward size={28} />
|
||
</button>
|
||
<button
|
||
className={`mp-ctrl-btn mp-ctrl-sm`}
|
||
onClick={toggleRepeat}
|
||
aria-label={t('player.repeat')}
|
||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||
>
|
||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Utility Footer */}
|
||
<div className="mp-footer">
|
||
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
|
||
<MicVocal size={20} />
|
||
<span>{t('player.lyrics')}</span>
|
||
</button>
|
||
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
|
||
<ListMusic size={20} />
|
||
<span>{t('queue.title')}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Queue Drawer */}
|
||
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
|
||
|
||
{/* Lyrics Drawer */}
|
||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||
|
||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
|
||
</div>
|
||
);
|
||
}
|