import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast, PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis, } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating, SubsonicAlbum } from '../api/subsonic'; import CachedImage from './CachedImage'; import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; import StarRating from './StarRating'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useLyricsStore } from '../store/lyricsStore'; import MarqueeText from './MarqueeText'; import LastfmIcon from './LastfmIcon'; import { useRadioMetadata } from '../hooks/useRadioMetadata'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; import PlaybackDelayModal from './PlaybackDelayModal'; import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { usePreviewStore } from '../store/previewStore'; import { usePerfProbeFlags } from '../utils/perfFlags'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`; } // Renders the playback clock without ever causing PlayerBar to re-render. // Updates the DOM directly via an imperative store subscription. const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) { const spanRef = useRef(null); useEffect(() => { if (spanRef.current) { spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime); } return subscribePlaybackProgress(state => { if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); }); }, []); return ; }); // Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render. const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) { const spanRef = useRef(null); useEffect(() => { const updateRemaining = () => { if (spanRef.current) { const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime); spanRef.current.textContent = `-${formatTime(remaining)}`; } }; updateRemaining(); return subscribePlaybackProgress(updateRemaining); }, [duration]); return ; }); export default function PlayerBar() { const { t } = useTranslation(); const navigate = useNavigate(); const [eqOpen, setEqOpen] = useState(false); const [showVolPct, setShowVolPct] = useState(false); const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime); const premuteVolumeRef = useRef(1); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); // currentTime is intentionally excluded — PlaybackTime handles it via direct DOM update. const { currentTrack, currentRadio, isPlaying, volume, togglePlay, next, previous, setVolume, stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, isQueueVisible, toggleQueue, starredOverrides, setStarredOverride, userRatingOverrides, setUserRatingOverride, 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, lastfmLoved: s.lastfmLoved, toggleLastfmLove: s.toggleLastfmLove, isQueueVisible: s.isQueueVisible, toggleQueue: s.toggleQueue, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, userRatingOverrides: s.userRatingOverrides, setUserRatingOverride: s.setUserRatingOverride, openContextMenu: s.openContextMenu, }))); const { lastfmSessionKey } = useAuthStore(); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const [floatingStyle, setFloatingStyle] = useState({}); const playerBarRef = useRef(null); const [utilityOverflow, setUtilityOverflow] = useState(false); const [utilityMenuOpen, setUtilityMenuOpen] = useState(false); const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full'); const utilityMenuRef = useRef(null); const utilityBtnRef = useRef(null); const [utilityMenuStyle, setUtilityMenuStyle] = useState({}); const volumeWheelMenuTimerRef = useRef(null); const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false); const perfFlags = usePerfProbeFlags(); useEffect(() => { if (!floatingPlayerBar) return; const updatePosition = () => { const sidebar = document.querySelector('.sidebar') as HTMLElement; const queue = document.querySelector('.queue-panel') as HTMLElement; const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0; const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0; setFloatingStyle({ left: leftOffset + 24, right: rightOffset + 24, width: 'auto', }); }; updatePosition(); const observer = new ResizeObserver(updatePosition); const sidebar = document.querySelector('.sidebar'); const queue = document.querySelector('.queue-panel'); if (sidebar) observer.observe(sidebar); if (queue) observer.observe(queue); window.addEventListener('resize', updatePosition); return () => { observer.disconnect(); window.removeEventListener('resize', updatePosition); }; }, [floatingPlayerBar]); useEffect(() => { const updateOverflow = () => { const width = playerBarRef.current?.clientWidth ?? window.innerWidth; const threshold = floatingPlayerBar ? 980 : 1140; setUtilityOverflow(width < threshold); }; updateOverflow(); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateOverflow) : null; const el = playerBarRef.current; if (ro && el) ro.observe(el); window.addEventListener('resize', updateOverflow); return () => { ro?.disconnect(); window.removeEventListener('resize', updateOverflow); }; }, [floatingPlayerBar]); useEffect(() => { if (!utilityOverflow) setUtilityMenuOpen(false); if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) { window.clearTimeout(volumeWheelMenuTimerRef.current); volumeWheelMenuTimerRef.current = null; } }, [utilityOverflow]); useEffect(() => { if (!utilityMenuOpen) return; const onDown = (e: MouseEvent) => { const target = e.target as Node; if (utilityBtnRef.current?.contains(target)) return; if (utilityMenuRef.current?.contains(target)) return; setUtilityMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setUtilityMenuOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [utilityMenuOpen]); useEffect(() => () => { if (volumeWheelMenuTimerRef.current != null) { window.clearTimeout(volumeWheelMenuTimerRef.current); } }, []); useEffect(() => { const onToggleEqualizer = () => setEqOpen(v => !v); window.addEventListener('psy:toggle-equalizer', onToggleEqualizer); return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer); }, []); useEffect(() => { if (!utilityMenuOpen) return; const MENU_WIDTH = 238; const MARGIN = 8; const updateMenuPos = () => { const btn = utilityBtnRef.current; if (!btn) return; const r = btn.getBoundingClientRect(); const left = Math.min( Math.max(r.right - MENU_WIDTH, MARGIN), window.innerWidth - MENU_WIDTH - MARGIN, ); setUtilityMenuStyle({ position: 'fixed', left, width: MENU_WIDTH, bottom: window.innerHeight - r.top + 8, zIndex: 10050, }); }; updateMenuPos(); window.addEventListener('resize', updateMenuPos); window.addEventListener('scroll', updateMenuPos, true); return () => { window.removeEventListener('resize', updateMenuPos); window.removeEventListener('scroll', updateMenuPos, true); }; }, [utilityMenuOpen]); const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const transportAnchorRef = useRef(null); const playSlotRef = useRef(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); const isStarred = currentTrack ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; const toggleStar = useCallback(async () => { if (!currentTrack) return; const next = !isStarred; setStarredOverride(currentTrack.id, next); try { if (next) await star(currentTrack.id, 'song'); else await unstar(currentTrack.id, 'song'); } catch { setStarredOverride(currentTrack.id, !next); } }, [currentTrack, isStarred, setStarredOverride]); const duration = currentTrack?.duration ?? 0; // Cover art: prefer radio station art, fall back to track art. // Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns. const radioCoverSrc = useMemo( () => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '', [currentRadio?.coverArt, currentRadio?.id] ); const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : ''; // 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 displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt; const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle')); const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—'); const coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]); const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : ''; const handleVolume = useCallback((e: React.ChangeEvent) => { setVolume(parseFloat(e.target.value)); }, [setVolume]); const handleVolumeWheel = useCallback((e: React.WheelEvent) => { 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]); const volumeStyle = { background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, }; const playerBarContent = ( <>
{/* Track Info */}
!isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()} data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined} > {isRadio ? ( currentRadio?.coverArt ? ( ) : (
) ) : displayCoverArt ? ( ) : (
)} {currentTrack && !isRadio && !showPreviewMeta && ( )}
{showPreviewMeta && ( {t('player.previewLabel')} )} !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId ? (e) => { e.preventDefault(); const album: SubsonicAlbum = { id: currentTrack.albumId, name: currentTrack.album, artist: currentTrack.artist, artistId: currentTrack.artistId ?? '', coverArt: currentTrack.coverArt, songCount: 0, duration: 0, }; openContextMenu(e.clientX, e.clientY, album, 'album'); } : undefined} /> !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} /> {currentTrack && !isRadio && !showPreviewMeta && ( { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} className="player-track-rating" ariaLabel={t('albumDetail.ratingLabel')} /> )} {isRadio && radioMeta.listeners != null && ( {t('radio.listenerCount', { count: radioMeta.listeners })} )}
{currentTrack && !isRadio && ( )} {currentTrack && !isRadio && lastfmSessionKey && ( )}
{/* Transport Controls */}
{isPreviewing && ( )}
{/* Waveform Seekbar / Radio live bar */}
{isRadio ? ( <> {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? ( <> {formatTime(radioMeta.elapsed)}
{formatTime(radioMeta.duration)} ) : ( <>
{t('radio.live')}
0:00 )} ) : ( <>
{perfFlags.disableWaveformCanvas ?
: }
{ const newVal = !localShowRemaining; setLocalShowRemaining(newVal); useThemeStore.getState().setShowRemainingTime(newVal); }} data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')} > {localShowRemaining ? : formatTime(duration)} )}
{utilityOverflow ? (
) : ( <> {/* EQ Button */} {/* Mini Player */} {/* Volume */}
{showVolPct && ( {Math.round(volume * 100)}% )} setShowVolPct(true)} onMouseLeave={() => setShowVolPct(false)} />
)} {/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} {utilityMenuOpen && createPortal(
{utilityMenuMode === 'full' && (
)} {utilityMenuMode === 'full' ? (
{showVolPct && ( {Math.round(volume * 100)}% )} setShowVolPct(true)} onMouseLeave={() => setShowVolPct(false)} />
) : (
{showVolPct && ( {Math.round(volume * 100)}% )} setShowVolPct(true)} onMouseLeave={() => setShowVolPct(false)} />
)}
, document.body )} {/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} {eqOpen && createPortal( <>
setEqOpen(false)} />
Equalizer
, document.body )}
setDelayModalOpen(false)} anchorRef={transportAnchorRef} /> ); if (floatingPlayerBar) { return createPortal(playerBarContent, document.body); } return playerBarContent; }