import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount'; import { useTranslation } from 'react-i18next'; import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useLyricsStore } from '../store/lyricsStore'; import { songToTrack } from '../utils/playback/songToTrack'; import { useCachedUrl } from '../components/CachedImage'; import CachedImage from '../components/CachedImage'; import { useRadioMetadata } from '../hooks/useRadioMetadata'; import { useDragSource, useDragDrop } from '../contexts/DragDropContext'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { useNpLayoutStore, NP_CARD_IDS, type NpCardId, type NpColumn, } from '../store/nowPlayingLayoutStore'; // ─── Helpers ────────────────────────────────────────────────────────────────── import { formatTime, formatCompact, isoToParts, buildContributorRows, type ContributorRow, } from '../utils/componentHelpers/nowPlayingHelpers'; import NpCardWrap from '../components/nowPlaying/NpCardWrap'; import NpColumnEl from '../components/nowPlaying/NpColumnEl'; import RadioView from '../components/nowPlaying/RadioView'; import Hero from '../components/nowPlaying/Hero'; import ArtistCard from '../components/nowPlaying/ArtistCard'; import AlbumCard from '../components/nowPlaying/AlbumCard'; import TopSongsCard from '../components/nowPlaying/TopSongsCard'; import CreditsCard from '../components/nowPlaying/CreditsCard'; import TourCard from '../components/nowPlaying/TourCard'; import DiscographyCard from '../components/nowPlaying/DiscographyCard'; import { useNowPlayingFetchers } from '../hooks/useNowPlayingFetchers'; import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove'; // ─── Main Page ──────────────────────────────────────────────────────────────── export default function NowPlaying() { const { t } = useTranslation(); const stableNavigate = usePlaybackLibraryNavigate(); const subsonicReady = useEnsurePlaybackServerOnMount(); const activeServerId = useAuthStore(s => s.activeServerId ?? ''); const currentTrack = usePlayerStore(s => s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const toggleQueue = usePlayerStore(s => s.toggleQueue); const audiomuseNavidromeEnabled = useAuthStore( s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), ); const enableBandsintown = useAuthStore(s => s.enableBandsintown); const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); const lastfmUsername = useAuthStore(s => s.lastfmUsername); const lastfmSessionKey = useAuthStore(s => s.lastfmSessionKey); const playTrackFn = usePlayerStore(s => s.playTrack); const radioMeta = useRadioMetadata(currentRadio ?? null); const songId = currentTrack?.id; const artistId = currentTrack?.artistId; const albumId = currentTrack?.albumId; const artistName = currentTrack?.artist ?? ''; // Entity fetchers (8 cached useEffects + their state) const { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, lfmTrack, lfmArtist, } = useNowPlayingFetchers({ songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, currentTrack, subsonicServerId: activeServerId, fetchEnabled: subsonicReady, }); // Star + Last.fm love + their toggle callbacks const lfmLoveEnabled = Boolean(lastfmUsername && lastfmSessionKey); const { starred, lfmLoved, toggleStar, toggleLfmLove } = useNowPlayingStarLove({ currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey, }); const openLyrics = useCallback(() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }, [isQueueVisible, toggleQueue, showLyrics]); const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800); const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : ''; const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : ''; const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey); const contributorRows = useMemo( () => buildContributorRows(songMeta, artistName), [songMeta, artistName], ); // Merge Subsonic artistInfo with Last.fm fallback: if Subsonic has no bio, // use Last.fm's artist bio so the card doesn't show up empty. const effectiveArtistInfo = useMemo(() => { if (!artistInfo && !lfmArtist?.bio) return null; if (artistInfo?.biography) return artistInfo; if (!lfmArtist?.bio) return artistInfo; return { ...(artistInfo ?? {}), biography: lfmArtist.bio, }; }, [artistInfo, lfmArtist]); const handleEnableBandsintown = useCallback(() => setEnableBandsintown(true), [setEnableBandsintown]); const handlePlayTopSong = useCallback((song: SubsonicSong) => { if (topSongs.length === 0) return; const queue = topSongs.map(songToTrack); const hit = queue.find(q => q.id === song.id); if (hit) playTrackFn(hit, queue); }, [topSongs, playTrackFn]); // ── Widget layout (drag-to-reorder, hide/show, reset) ──────────────────── const layoutCards = useNpLayoutStore(s => s.cards); const moveCard = useNpLayoutStore(s => s.moveCard); const setCardVisible = useNpLayoutStore(s => s.setVisible); const resetLayout = useNpLayoutStore(s => s.reset); const { isDragging: dndActive, payload: dndPayload } = useDragDrop(); const [dragOver, setDragOver] = useState<{ col: NpColumn; idx: number } | null>(null); const [layoutMenuOpen, setLayoutMenuOpen] = useState(false); // Parse the current drag payload to know whether it's an np-card drag const draggingCardId: NpCardId | null = useMemo(() => { if (!dndActive || !dndPayload) return null; try { const parsed = JSON.parse(dndPayload.data); if (parsed?.kind === 'np-card' && NP_CARD_IDS.includes(parsed.id)) return parsed.id as NpCardId; } catch { /* not a card payload */ } return null; }, [dndActive, dndPayload]); // Clear the drop indicator when the drag ends (no psy-drop on our target) useEffect(() => { if (!draggingCardId) setDragOver(null); }, [draggingCardId]); const toggleCardVisible = useCallback((id: NpCardId, next: boolean) => { setCardVisible(id, next); }, [setCardVisible]); const onColumnHover = useCallback((col: NpColumn, idx: number) => { setDragOver(prev => (prev && prev.col === col && prev.idx === idx) ? prev : { col, idx }); }, []); // Ref mirror of dragOver so the document-level psy-drop handler always sees // the latest hovered column/index regardless of closure timing. const dragOverRef = useRef(dragOver); dragOverRef.current = dragOver; // Global psy-drop listener: catches drops anywhere on the page (even below a // column when the cursor left the column bounds), then uses dragOverRef to // decide which column/index the user actually meant. useEffect(() => { if (!draggingCardId) return; const onPsyDrop = (evt: Event) => { const ce = evt as CustomEvent<{ data: string }>; try { const parsed = JSON.parse(ce.detail?.data ?? ''); if (parsed?.kind !== 'np-card' || !NP_CARD_IDS.includes(parsed.id)) return; const over = dragOverRef.current; if (over) { moveCard(parsed.id as NpCardId, over.col, over.idx); } } catch { /* ignore non-card drops */ } setDragOver(null); }; document.addEventListener('psy-drop', onPsyDrop as EventListener); return () => document.removeEventListener('psy-drop', onPsyDrop as EventListener); }, [draggingCardId, moveCard]); // Close layout menu on outside click useEffect(() => { if (!layoutMenuOpen) return; const onDoc = (e: MouseEvent) => { const el = e.target as HTMLElement | null; if (!el?.closest('.np-dash-toolbar-menu-wrap')) setLayoutMenuOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [layoutMenuOpen]); // ── Render ──────────────────────────────────────────────────────────────── return (
{currentRadio && !currentTrack ? ( ) : currentTrack ? (
{(() => { const renderCard = (id: NpCardId): React.ReactNode => { switch (id) { case 'album': return ( ); case 'topSongs': return ( ); case 'credits': return ; case 'artist': return ( ); case 'discography': return ( ); case 'tour': return ( ); } }; const cardLabel = (id: NpCardId): string => { const k: Record = { album: 'nowPlaying.fromAlbum', topSongs: 'nowPlaying.topSongs', credits: 'nowPlayingInfo.songInfo', artist: 'nowPlaying.aboutArtist', discography: 'nowPlaying.discography', tour: 'nowPlayingInfo.onTour', }; return t(k[id]); }; const visibleCards = layoutCards.filter(c => c.visible); const hiddenCards = layoutCards.filter(c => !c.visible); const renderColumn = (col: NpColumn) => { const cards = layoutCards.filter(c => c.column === col && c.visible && c.id !== draggingCardId, ); const isOver = dragOver?.col === col; return ( {cards.map((c, idx) => ( {isOver && dragOver.idx === idx &&
} {renderCard(c.id)} ))} {isOver && dragOver.idx === cards.length &&
} ); }; return ( <>
{layoutMenuOpen && (
{t('nowPlaying.visibleCards', 'Visible cards')}
{visibleCards.map(c => ( ))} {hiddenCards.length > 0 && ( <>
{t('nowPlaying.hiddenCards', 'Hidden cards')}
{hiddenCards.map(c => ( ))} )}
)}
{renderColumn('left')} {renderColumn('right')}
); })()}
) : (

{t('nowPlaying.nothingPlaying')}

)}
); }