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 } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '../store/authStore'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar, setRating } 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'; 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(usePlayerStore.getState().currentTime); } return usePlayerStore.subscribe(state => { if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); }); }, []); return ; }); export default function PlayerBar() { const { t } = useTranslation(); const navigate = useNavigate(); const [eqOpen, setEqOpen] = useState(false); const [showVolPct, setShowVolPct] = useState(false); 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, } = 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, }))); const { lastfmSessionKey } = useAuthStore(); 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) : ''; const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 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))); }, [volume, setVolume]); const volumeStyle = { background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, }; return ( ); }