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 '@/features/playback/hooks/usePlaybackLibraryNavigate'; import { useRadioMetadata } from '@/features/radio'; import { useRadioMprisSync } from '@/features/radio'; import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress'; import PlaybackDelayModal from '@/features/playback/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 '@/features/playback/hooks/useFloatingPlayerBar'; import { useUtilityOverflowMenu } from '@/features/playback/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(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(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); // 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) => { 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, 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 = ( <>
{utilityOverflow ? (
) : ( <> {isLayoutVisible('playbackRate') && ( )} {isLayoutVisible('equalizer') && ( )} {isLayoutVisible('miniPlayer') && ( )} )} {utilityMenuOpen && ( 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( <>
setEqOpen(false)} />
Equalizer
, document.body )}
setDelayModalOpen(false)} anchorRef={transportAnchorRef} /> ); if (floatingPlayerBar) { return createPortal(playerBarContent, document.body); } return playerBarContent; }