import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
SkipBack, SkipForward, Square, Repeat, Repeat1, Heart,
Shuffle, ListMusic, ChevronDown, Star, MicVocal,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
import { useAlbumCoverRef, useArtistCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
import { useCachedUrl } from '@/ui/CachedImage';
import { useArtistFanart } from '@/cover/useArtistFanart';
import { backdropFromConfig } from '@/cover/artistBackdrop';
import { useThemeStore } from '@/store/themeStore';
import { useFsIdleFade } from '@/features/fullscreenPlayer/hooks/useFsIdleFade';
import { useQueueTrackAt } from '@/features/queue';
import { WaveformSeek } from '@/features/waveform';
import { FsQueueModal } from '@/features/fullscreenPlayer/components/FsQueueModal';
import { FsLyricsApple } from '@/features/fullscreenPlayer/components/FsLyricsApple';
import { FsPlayBtn } from '@/features/fullscreenPlayer/components/FsPlayBtn';
import { FsClock } from '@/features/fullscreenPlayer/components/FsClock';
import { FsTimeReadout } from '@/features/fullscreenPlayer/components/FsTimeReadout';
interface Props {
onClose: () => void;
}
/**
* Fullscreen background image that eases in once its pixels are loaded, so a
* new background fades up from the empty backdrop instead of hard-cutting.
*
* Mount it with `key={url}` so every source gets a fresh element (and a fresh
* `loaded=false`). Both load paths are covered: `onLoad` for a network/disk
* fetch, and the `ref`'s `complete` check for an already-cached image whose
* `load` event can fire before React attaches the handler (e.g. skipping back
* to a recently shown artist) — without it the background would stay black.
*/
function FsBackground({ url }: { url: string }) {
const [loaded, setLoaded] = useState(false);
if (!url) return
;
return (
setLoaded(true)}
ref={(el) => {
if (el?.complete) setLoaded(true);
}}
alt=""
aria-hidden="true"
draggable={false}
/>
);
}
export default function FullscreenPlayerStatic({ onClose }: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const shuffleUpcomingQueue = usePlayerStore(s => s.shuffleUpcomingQueue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueLen = usePlayerStore(s => s.queueItems.length);
// Derive the boolean inside the selector so the cluster only re-renders when
// the star actually flips, not on any unrelated track's star change.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
// Album-keyed cover ref so the cover stays stable across track changes within
// the same album. A per-track ref re-keys on `track.coverArt` (Navidrome hands
// out `mf-` per track), which the distinct-disc heuristic mistakes for
// per-disc art and reloads the cover on every song change. Keying on albumId
// sidesteps that — same lesson as the artist portrait keying on artistId.
// `usePlaybackCoverArt` still re-scopes it to the playback server.
const playbackCoverRef =
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
// One high-res cover (cucadmuh's fullRes 2000px path) feeds the foreground
// thumbnail — crisp instead of the old low-res tier. It is no longer a
// background source (see below).
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
const thumbUrl = coverUrl;
// Background (§28). The album cover is deliberately NOT a background source —
// it only ever feeds the foreground thumbnail. The user-configurable source
// list (fanart / Navidrome artist image, no banner) drives the rest: with the
// scraper on and fanart first, the fanart.tv 16:9 image is the background and
// while it resolves the background stays empty (no album/artist flash), then
// falls back per the list; with the scraper off the fanart source reports a
// non-pending miss, so the chain steps straight to the Navidrome artist image.
const fsBackdropCfg = useThemeStore((s) => s.backdrops.fullscreenPlayer);
const fanart = useArtistFanart(currentTrack?.artistId, {
artistName: currentTrack?.artist,
albumTitle: currentTrack?.album,
});
const artistCoverRef =
useArtistCoverRef(currentTrack?.artistId, undefined, undefined, { libraryResolve: false }) ??
undefined;
const artistImage = usePlaybackCoverArt(artistCoverRef, 2000, { fullRes: true });
const artistImgUrl = useCachedUrl(artistImage.src, artistImage.cacheKey, true);
const fsBackdrop = backdropFromConfig(fsBackdropCfg.sources, { fanart, navidrome: artistImgUrl });
const bgUrl = fsBackdropCfg.enabled ? fsBackdrop.url : '';
const nextTrack = useQueueTrackAt(queueIndex + 1);
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
const controlsRef = useRef(null);
const [queueOpen, setQueueOpen] = useState(false);
const [lyricsOpen, setLyricsOpen] = useState(false);
const metaParts = useMemo(
() => [currentTrack?.year?.toString(), currentTrack?.genre].filter(Boolean) as string[],
[currentTrack?.year, currentTrack?.genre],
);
// Override-aware rating (a just-set rating lives in the override before it syncs
// back onto the track object).
const rating = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return 0;
return track.id in s.userRatingOverrides ? s.userRatingOverrides[track.id] : (track.userRating ?? 0);
});
// Hover preview for the clickable rating stars (0 = no preview).
const [hoverRating, setHoverRating] = useState(0);
const applyRating = useCallback((stars: number) => {
if (!currentTrack) return;
// Click the current rating again to clear it (matches StarRating's toggle-off).
queueSongRating(currentTrack.id, rating === stars ? 0 : stars);
}, [currentTrack, rating]);
return (
{/* Sharp background — no blur; eases in once its pixels are loaded. */}
{/* Top bar */}