mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(fullscreenPlayer): co-locate fullscreen player feature into features/fullscreenPlayer
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { FsClock } from '@/features/fullscreenPlayer/components/FsClock';
|
||||
|
||||
describe('FsClock', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 1, 21, 59, 0));
|
||||
resetAuthStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses the 24-hour clock format from Settings → System', () => {
|
||||
useAuthStore.setState({ clockFormat: '24h' });
|
||||
renderWithProviders(<FsClock />);
|
||||
expect(screen.getByText('21:59')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/AM|PM/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the 12-hour clock format when selected', () => {
|
||||
useAuthStore.setState({ clockFormat: '12h' });
|
||||
renderWithProviders(<FsClock />);
|
||||
expect(screen.getByText('09:59 PM')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { formatClockTime } from '@/utils/format/formatClockTime';
|
||||
|
||||
/**
|
||||
* Standalone wall-clock for the fullscreen player. Owns its own state so the
|
||||
* static player never re-renders for the time. Ticks every 30s (enough to catch
|
||||
* the minute rollover) and pauses while the window/tab is hidden.
|
||||
*/
|
||||
export const FsClock = memo(function FsClock() {
|
||||
const clockFormat = useAuthStore(s => s.clockFormat);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const formatNow = () => formatClockTime(Date.now(), clockFormat, i18n.language);
|
||||
const [time, setTime] = useState(formatNow);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(formatClockTime(Date.now(), clockFormat, i18n.language));
|
||||
}, [clockFormat, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClockTime(Date.now(), clockFormat, i18n.language));
|
||||
const sync = () => {
|
||||
if (document.hidden) {
|
||||
if (id !== undefined) { clearInterval(id); id = undefined; }
|
||||
} else {
|
||||
tick();
|
||||
if (id === undefined) id = window.setInterval(tick, 30_000);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
return () => {
|
||||
if (id !== undefined) clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
};
|
||||
}, [clockFormat, i18n.language]);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLyrics, type WordLyricsLine } from '@/hooks/useLyrics';
|
||||
import { useWordLyricsSync } from '@/hooks/useWordLyricsSync';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
|
||||
import type { LrcLine } from '@/api/lrclib';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { EaseScroller, targetForFraction } from '@/utils/ui/easeScroll';
|
||||
|
||||
// Fullscreen synced lyrics.
|
||||
// Full-screen scrollable list. The active line auto-scrolls following the
|
||||
// "Lyrics scroll style" setting: 'apple' anchors it ~35% from the top, 'classic'
|
||||
// centres it. Word-sync runs imperatively (no React re-renders on every time tick).
|
||||
// User scroll pauses auto-scroll for 4 s then resumes.
|
||||
export const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, plainLyrics, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
const sidebarLyricsStyle = useAuthStore(s => s.sidebarLyricsStyle);
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
: (syncedLines as LrcLine[] | null);
|
||||
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lineSrc! : [];
|
||||
|
||||
// React state only for the active line index — changes are infrequent.
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const activeIdxRef = useRef(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollerRef = useRef<EaseScroller | null>(null);
|
||||
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const isUserScroll = useRef(false);
|
||||
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
scrollerRef.current?.stop();
|
||||
scrollerRef.current = el ? new EaseScroller(el) : null;
|
||||
}, []);
|
||||
|
||||
// Reset everything on track change.
|
||||
useEffect(() => {
|
||||
lineRefs.current = [];
|
||||
activeIdxRef.current = -1;
|
||||
setActiveIdx(-1);
|
||||
scrollerRef.current?.jump(0);
|
||||
}, [currentTrack?.id]);
|
||||
|
||||
// Subscribe to playback time — only triggers React setState when line changes.
|
||||
useEffect(() => {
|
||||
if (!hasSynced) return;
|
||||
const apply = (time: number) => {
|
||||
const ls = linesRef.current;
|
||||
if (!ls.length) return;
|
||||
const idx = ls.reduce((acc, line, i) => time >= line.time ? i : acc, -1);
|
||||
if (idx !== activeIdxRef.current) {
|
||||
activeIdxRef.current = idx;
|
||||
setActiveIdx(idx);
|
||||
}
|
||||
};
|
||||
apply(getPlaybackProgressSnapshot().currentTime);
|
||||
return subscribePlaybackProgress(s => apply(s.currentTime));
|
||||
}, [hasSynced, currentTrack?.id]);
|
||||
|
||||
// Scroll the active line into view, honouring the "Lyrics scroll style" setting
|
||||
// (same as the sidebar LyricsPane): 'apple' ease-scrolls to ~35% from the top,
|
||||
// 'classic' centres the active line via native smooth scrollIntoView.
|
||||
useEffect(() => {
|
||||
if (activeIdx < 0 || isUserScroll.current) return;
|
||||
const el = lineRefs.current[activeIdx];
|
||||
const box = containerRef.current;
|
||||
if (!el || !box || !scrollerRef.current) return;
|
||||
if (sidebarLyricsStyle === 'apple') {
|
||||
scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
|
||||
} else {
|
||||
scrollerRef.current.stop();
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [activeIdx, sidebarLyricsStyle]);
|
||||
|
||||
const { setWordRef } = useWordLyricsSync({
|
||||
enabled: useWords,
|
||||
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
|
||||
currentTrack,
|
||||
classPrefix: 'fsa',
|
||||
});
|
||||
|
||||
const handleUserScroll = useCallback(() => {
|
||||
scrollerRef.current?.stop();
|
||||
isUserScroll.current = true;
|
||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||||
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
|
||||
if (!target || duration <= 0) return;
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
if (!currentTrack || loading) return null;
|
||||
|
||||
const isPlain = !hasSynced && !!plainLyrics;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
|
||||
ref={setContainerRef}
|
||||
onWheel={handleUserScroll}
|
||||
onTouchMove={handleUserScroll}
|
||||
onClick={handleClick}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="fsa-lyrics-top-pad" />
|
||||
|
||||
{hasSynced && (useWords
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0
|
||||
? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fsa-lyric-word"
|
||||
ref={setWordRef(i, j)}
|
||||
>{w.text}</span>
|
||||
))
|
||||
: (line.text || ' ')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={el => { lineRefs.current[i] = el; }}
|
||||
className={`fsa-lyric-line${i === activeIdx ? ' fsal-active' : i < activeIdx ? ' fsal-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || ' '}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!hasSynced && plainLyrics && (
|
||||
<div className="fsa-plain-lyrics">
|
||||
{plainLyrics.split('\n').map((line, i) => (
|
||||
<p key={i} className="fsa-plain-line">{line || ' '}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fsa-lyrics-bottom-pad" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { Moon, Pause, Play, Sunrise } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/utils/format/playbackScheduleFormat';
|
||||
import PlaybackDelayModal from '@/components/PlaybackDelayModal';
|
||||
import PlaybackScheduleBadge from '@/components/PlaybackScheduleBadge';
|
||||
|
||||
// Play/Pause button (isolated — subscribes to isPlaying only).
|
||||
export const FsPlayBtn = memo(function FsPlayBtn({
|
||||
controlsAnchorRef,
|
||||
}: {
|
||||
controlsAnchorRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
|
||||
const playSlotRef = useRef<HTMLSpanElement>(null);
|
||||
const scheduleRemaining = usePlaybackScheduleRemaining();
|
||||
return (
|
||||
<>
|
||||
<span ref={playSlotRef} className="playback-transport-play-wrap">
|
||||
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} className="playback-schedule-badge--fs" />
|
||||
<button
|
||||
type="button"
|
||||
className="fs-btn fs-btn-play"
|
||||
{...playPauseBind}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{scheduleRemaining != null ? (
|
||||
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--fs`}>
|
||||
{scheduleRemaining.mode === 'pause'
|
||||
? <Moon size={12} strokeWidth={2.5} />
|
||||
: <Sunrise size={12} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { memo, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
import { formatTrackTime } from '@/utils/format/formatDuration';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semi-transparent "Up next" overlay for the fullscreen player — lists the
|
||||
* upcoming queue (no blur, in keeping with the static player). Clicking a row
|
||||
* jumps to that queue item (same-queue jump as the queue panel).
|
||||
*/
|
||||
export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
// Re-resolve as the resolver cache fills.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { track: Track; absIdx: number }[] = [];
|
||||
for (let i = queueIndex + 1; i < queueItems.length; i++) {
|
||||
const ref = queueItems[i];
|
||||
if (ref) out.push({ track: resolveQueueTrack(ref), absIdx: i });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queueItems, queueIndex, version]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsq-backdrop"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fsUpNext')}
|
||||
>
|
||||
<div className="fsq-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="fsq-header">
|
||||
<span className="fsq-title">{t('player.fsUpNext')}</span>
|
||||
<button className="fsq-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="fsq-list">
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="fsq-empty">{t('player.fsQueueEmpty')}</div>
|
||||
) : (
|
||||
upcoming.map(({ track, absIdx }) => (
|
||||
<button
|
||||
key={`${track.id}:${absIdx}`}
|
||||
className="fsq-item"
|
||||
onClick={() => {
|
||||
playTrack(track, undefined, undefined, undefined, absIdx);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="fsq-item-pos">{absIdx + 1}</span>
|
||||
<span className="fsq-item-info">
|
||||
<span className="fsq-item-title">{track.title}</span>
|
||||
<span className="fsq-item-artist">{track.artist}</span>
|
||||
</span>
|
||||
<span className="fsq-item-dur">{formatTrackTime(track.duration ?? 0)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/store/playbackProgress';
|
||||
import { formatTrackTime } from '@/utils/format/formatDuration';
|
||||
|
||||
/**
|
||||
* Centered "current / total" readout for the control bar. Updates the current
|
||||
* time imperatively from the playback-progress store — no React re-render per
|
||||
* tick (same pattern as FsSeekbar).
|
||||
*/
|
||||
export const FsTimeReadout = memo(function FsTimeReadout({ duration }: { duration: number }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (state: { currentTime: number }) => {
|
||||
if (ref.current) ref.current.textContent = formatTrackTime(state.currentTime);
|
||||
};
|
||||
apply(getPlaybackProgressSnapshot());
|
||||
return subscribePlaybackProgress(apply);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="fsp-time">
|
||||
<span ref={ref} /> / {formatTrackTime(duration)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
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 '@/store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
|
||||
import { useAlbumCoverRef, useArtistCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '@/hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '@/ui/CachedImage';
|
||||
import { useArtistFanart } from '@/cover/useArtistFanart';
|
||||
import { backdropFromConfig } from '@/cover/artistBackdrop';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useFsIdleFade } from '@/hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '@/hooks/useQueueTracks';
|
||||
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 <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />;
|
||||
return (
|
||||
<img
|
||||
className={`fsp-bg${loaded ? ' is-loaded' : ''}`}
|
||||
src={url}
|
||||
onLoad={() => 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-<trackId>` 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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="fsp"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Sharp background — no blur; eases in once its pixels are loaded. */}
|
||||
<FsBackground key={bgUrl} url={bgUrl} />
|
||||
<div className="fsp-scrim" aria-hidden="true" />
|
||||
<div className="fsp-vignette" aria-hidden="true" />
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="fsp-top">
|
||||
<div className="fsp-nowplaying">
|
||||
<span className="fsp-nowplaying-label">{t('player.fsNowPlaying')}</span>
|
||||
{queueLen > 0 && (
|
||||
<span className="fsp-nowplaying-pos">{t('player.fsTrackPosition', { current: queueIndex + 1, total: queueLen })}</span>
|
||||
)}
|
||||
</div>
|
||||
<FsClock />
|
||||
</div>
|
||||
|
||||
<button className="fsp-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="fsp-foot">
|
||||
<div className="fsp-info-row">
|
||||
{/* Big cover — bottom-aligned with the text, top pokes above the bar */}
|
||||
<div className="fsp-cover">
|
||||
{thumbUrl
|
||||
? <img className="fsp-cover-img" src={thumbUrl} alt="" draggable={false} />
|
||||
: <div className="fsp-cover-img fsp-cover-img--empty" />}
|
||||
</div>
|
||||
<div className="fsp-info-text">
|
||||
<p className="fsp-title">{currentTrack?.title ?? '—'}</p>
|
||||
<p className="fsp-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
{currentTrack && (
|
||||
<div className="fsp-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fsp-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span
|
||||
className="fsp-stars"
|
||||
role="radiogroup"
|
||||
aria-label={t('albumDetail.ratingLabel')}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const n = i + 1;
|
||||
const filled = (hoverRating || rating) >= n;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="fsp-star-btn"
|
||||
role="radio"
|
||||
aria-checked={rating === n}
|
||||
aria-label={`${n}`}
|
||||
onMouseEnter={() => setHoverRating(n)}
|
||||
onClick={() => applyRating(n)}
|
||||
>
|
||||
<Star size={16} fill={filled ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nextTrack && (
|
||||
<p className="fsp-next">{t('player.fsNext')}: {nextTrack.artist} – {nextTrack.title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fsp-controls" ref={controlsRef}>
|
||||
<div className="fsp-transport">
|
||||
<button className="fsp-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={controlsRef} />
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fsp-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FsTimeReadout duration={duration} />
|
||||
|
||||
<div className="fsp-actions">
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={() => setQueueOpen(true)} aria-label={t('queue.title')} data-tooltip={t('queue.title')}>
|
||||
<ListMusic size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${lyricsOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsOpen(v => !v)}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={20} />
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={20} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={shuffleUpcomingQueue} aria-label={t('player.shuffle')} data-tooltip={t('player.shuffle')}>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* True waveform seekbar (cucadmuh's idea) instead of the thin bar. */}
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
|
||||
{queueOpen && <FsQueueModal onClose={() => setQueueOpen(false)} />}
|
||||
|
||||
{/* Scrolling synced lyrics (reuses FsLyricsApple) in a semi-transparent
|
||||
overlay over the upper area. */}
|
||||
{lyricsOpen && (
|
||||
<div className="fsp-lyrics-overlay">
|
||||
<FsLyricsApple currentTrack={currentTrack} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Fullscreen player feature — the immersive full-window now-playing view
|
||||
* (artwork, Apple-style synced lyrics, clock, transport, queue modal) rendered
|
||||
* by the app shell. Its sub-components (Fs*) are internal.
|
||||
*
|
||||
* Note: `hooks/useWindowFullscreenState` is NOT part of this feature — it tracks
|
||||
* the OS window's fullscreen state (app-shell concern), not this player view.
|
||||
*/
|
||||
export { default } from './components/FullscreenPlayerStatic';
|
||||
Reference in New Issue
Block a user