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, useMemo, 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 { 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(() => { 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(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). 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 (
e.stopPropagation()}>

{t('queue.title')}

{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
{queue.length === 0 ? (
{t('queue.emptyQueue')}
) : (
{virtualItems.map(vi => { const idx = vi.index; const track = resolveQueueTrack(queue[idx]); const isActive = idx === queueIndex; return (
{ playTrack(track, undefined, undefined, undefined, idx); onClose(); }} >
{isActive && } {track.title}
{track.artist}
{formatTrackTime(track.duration)}
); })}
)}
); } // ── Lyrics Drawer ───────────────────────────────────────────────────────────── function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) { const { t } = useTranslation(); return (
e.stopPropagation()}>

{t('player.lyrics')}

); } // ── 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(null); const playSlotRef = useRef(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, isStarred]); // Scrubber touch/mouse drag const scrubberRef = useRef(null); const isDragging = useRef(false); const pendingSeekRef = useRef(null); const [previewProgress, setPreviewProgress] = useState(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]); useEffect(() => { pendingSeekRef.current = null; setPreviewProgress(null); }, [currentTrack?.id]); // Drawers const [showQueue, setShowQueue] = useState(false); const [showLyrics, setShowLyrics] = useState(false); // ── Empty state ── if (!currentTrack) { return (
{t('sidebar.nowPlaying')}

{t('nowPlaying.nothingPlaying')}

); } 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 (
{/* Header */}
{t('sidebar.nowPlaying')}
{/* Cover Art */}
{resolvedCover ? ( ) : (
)}
{/* Track Metadata */}
{currentTrack.title}
{currentTrack.artists && currentTrack.artists.length > 0 ? ( { void navigatePlaybackLibrary(`/artist/${id}`); }} as="none" linkTag="span" linkClassName="mp-artist-link" /> ) : ( 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} )}
{(() => { const parts = [ currentTrack.year, currentTrack.genre, currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null, ].filter(Boolean); return parts.length > 0 ?
{parts.join(' • ')}
: null; })()}
{/* Scrubber */}
onScrubStart(e.clientX)} onTouchStart={e => onScrubStart(e.touches[0].clientX)} >
{formatTrackTime(effectiveTime)} -{formatTrackTime(Math.max(0, duration - effectiveTime))}
{/* Transport Controls */}
{/* Utility Footer */}
{/* Queue Drawer */} {showQueue && setShowQueue(false)} />} {/* Lyrics Drawer */} {showLyrics && setShowLyrics(false)} currentTrack={currentTrack} />} setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
); }