mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1) New lean fullscreen player: sharp full-bleed background (artist photo, cover fallback), no blur / no continuous animations. Bottom-left big cover bottom- flush with a full-width semi-transparent text bar (title with queue position, artist, year/genre + rating stars, next-up); control row of transport · center time · actions; full-width seekbar; live clock. Only the seekbar, time readout and clock update at runtime, each owning its state (no per-tick re-render). Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue helpers. Wired in AppShell; old player kept for A/B. * feat(fullscreen-player): true waveform seekbar instead of thin bar Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller canvas grows the bottom cluster upward, shifting the info/control rows up. Height clamped to 32-52px. * feat(fullscreen-player): up-next popover + control-bar styling - Queue button opens a semi-transparent 'Up next' popover anchored bottom right; clicking a row jumps to that queue item. - Larger bottom-right action buttons. - Control row gets its own darker semi-transparent bar (touches the info bar above) for contrast; play button matches the plain transport buttons (no white circle, same size). * feat(fullscreen-player): high-res (2000px) background cover Fetch the full-screen background cover at the 2000px tier via the existing on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP outside the backfill pipeline) instead of the low-res 500px pipeline tier. usePlaybackCoverArt now forwards a fullRes option to useCoverArt. * feat(fullscreen-player): scrolling lyrics overlay + control-bar polish - Lyrics button next to Queue toggles a centered, dark semi-transparent scrolling-lyrics overlay (reuses FsLyricsApple). - Control bar darkened to match the lyrics overlay (0.88). - Close button sized down to harmonize with the clock. * feat(fullscreen-player): make rating stars clickable The rating stars next to the year were display-only. Wire them to queueSongRating (same path as the player bar / context menu): click to set, click the current value to clear, with a hover preview. Keeps the lucide outline look to match the rest of the control bar. * fix(fullscreen-player): stable, high-res album cover The foreground cover was keyed per track, so Navidrome's per-track `mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded the cover on every song change within the same album. Key it on albumId (via useAlbumCoverRef) so it stays put while the album is unchanged. It also used the low-res tier; reuse the fullRes 2000px cover already fetched for the background so the foreground is crisp and both share a single fetch/decode. * refactor(fullscreen-player): remove the old player and its settings The static rebuild is now the only fullscreen player. Delete the old FullscreenPlayer component, its old-only parts (FsArt, FsPortrait, FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test. Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle, showFsArtistPortrait, fsPortraitDim) along with the Appearance 'Fullscreen player' section and its search entry. The new player always shows the artist photo with cover fallback and has its own lyrics toggle. Shared building blocks used by the static player (FsLyricsApple, FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait) are kept. * i18n(fullscreen-player): translate the new player's strings Wire the static player's previously English-only strings through i18n across all 9 locales: now-playing label, track position, up-next label, queue/lyrics/shuffle controls, and the queue overlay (title, empty, close). Reuses existing queue.title / common.close keys; adds six new keys to the player namespace. * docs(changelog): note fullscreen player rebuild (#1001)
This commit is contained in:
committed by
GitHub
parent
c674e4515b
commit
dc4eef1a97
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* `FullscreenPlayer` characterization (Phase F5c).
|
||||
*
|
||||
* Includes the §4.5 regression test from the v2 plan — the cover image
|
||||
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
|
||||
* third argument selects the fallback path that avoids a double
|
||||
* crossfade (fetchUrl → blobUrl). A refactor that "tidies up" the
|
||||
* useCachedUrl call sites would silently regress the FS player.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined),
|
||||
unstar: vi.fn(async () => undefined),
|
||||
getLyricsBySongId: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
|
||||
// so we can assert the third positional arg `false` is preserved.
|
||||
vi.mock('./CachedImage', async () => {
|
||||
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
|
||||
return {
|
||||
...actual,
|
||||
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
|
||||
};
|
||||
});
|
||||
|
||||
import FullscreenPlayer from './FullscreenPlayer';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — render', () => {
|
||||
it('renders the labelled Fullscreen Player dialog', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the Close Fullscreen button', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
// The component calls `useCachedUrl` twice:
|
||||
// - line 338: for the small art box (default behaviour, opt=true)
|
||||
// - line 674: for the cover (opt=false, no fetchUrl fallback)
|
||||
// The `false` arg is load-bearing — it avoids a double crossfade by
|
||||
// routing through the cache-only path. Pin it.
|
||||
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('album-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — control wiring', () => {
|
||||
it('clicking Close Fullscreen calls the onClose prop', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const onClose = vi.fn();
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={onClose} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Close Fullscreen'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Stop calls stop()', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Stop'));
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 5 });
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Previous Track'));
|
||||
expect(prevSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Next Track calls next()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 0,
|
||||
currentTrack: makeTrack({ id: 'a' }),
|
||||
});
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Next Track'));
|
||||
expect(nextSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Repeat cycles via toggleRepeat', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
fireEvent.click(getByLabelText('Repeat'));
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
|
||||
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
|
||||
import { FsArt } from './fullscreenPlayer/FsArt';
|
||||
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
|
||||
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
|
||||
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
|
||||
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
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);
|
||||
// Derive isStarred inside the selector so we only re-render when the boolean
|
||||
// actually flips — not when any unrelated track's star status changes.
|
||||
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, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||
|
||||
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
|
||||
const artUrl = artCover.src;
|
||||
const artKey = artCover.cacheKey;
|
||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||
const coverUrl = portraitCover.src;
|
||||
const coverKey = portraitCover.cacheKey;
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Dynamic accent color extracted from the current album cover, applied as
|
||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||
// same-album tracks reuse the color without re-fetching.
|
||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||
// around the current index keeps it warm).
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
useEffect(() => {
|
||||
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
|
||||
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
|
||||
nextTrack.albumId,
|
||||
nextTrack.coverArt,
|
||||
300,
|
||||
);
|
||||
getCachedBlob(url, key).catch(() => {});
|
||||
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
|
||||
const metaParts = useMemo(() => [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean), [currentTrack]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Track title — massive statement */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Artist — secondary, below track */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-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={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
|
||||
// Album art box — crossfades layers so old art stays visible while new loads.
|
||||
// Uses 300px thumbnails (portrait fallback uses 500px separately).
|
||||
//
|
||||
// Why onLoad instead of new Image() preload:
|
||||
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
|
||||
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
|
||||
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
|
||||
// before we flip it to 1.
|
||||
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
|
||||
// true = show raw fetchUrl immediately as fallback while blob resolves.
|
||||
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
|
||||
// Showing the URL directly avoids the multi-second blank wait.
|
||||
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
|
||||
|
||||
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
const id = ++counter.current;
|
||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const handleLoad = useCallback((id: number) => {
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
|
||||
}, []);
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => handleLoad(l.id)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
function formatClock(): string {
|
||||
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [time, setTime] = useState(formatClock);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClock());
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// Lyrics settings popover — shown above the mic button.
|
||||
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
|
||||
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="fslm-panel" ref={panelRef}>
|
||||
<div className="fslm-row">
|
||||
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
|
||||
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLyrics}
|
||||
onChange={e => setLyrics(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
|
||||
{(['rail', 'apple'] as const).map(style => (
|
||||
<button
|
||||
key={style}
|
||||
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
|
||||
onClick={() => setStyle(style)}
|
||||
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
|
||||
>
|
||||
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } 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 type { LrcLine } from '../../api/lrclib';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
// Classic 5-line rail lyrics (original "Rail" style).
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
export const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
|
||||
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 linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lineSrc! : [];
|
||||
|
||||
const activeIdx = usePlayerStore(s => {
|
||||
const ls = linesRef.current;
|
||||
if (ls.length === 0) return -1;
|
||||
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
|
||||
});
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const slotH = useRef(window.innerHeight * 0.06);
|
||||
useEffect(() => {
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const handleLineClick = 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]);
|
||||
|
||||
const { setWordRef } = useWordLyricsSync({
|
||||
enabled: useWords,
|
||||
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
|
||||
currentTrack,
|
||||
classPrefix: 'fsr',
|
||||
});
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fsr-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fsr-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
{useWords
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0 ? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fsr-lyric-word"
|
||||
ref={setWordRef(i, j)}
|
||||
>{w.text}</span>
|
||||
)) : (line.text || ' ')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const FsPlayBtn = memo(function FsPlayBtn({
|
||||
: <Sunrise size={12} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
) : isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Artist portrait — right half, crossfades on track change.
|
||||
export const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (layers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fs-portrait-wrap" aria-hidden="true">
|
||||
{layers.map(layer => (
|
||||
<img
|
||||
key={layer.id}
|
||||
src={layer.url}
|
||||
className="fs-portrait"
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
// Full-width seekbar — imperative DOM updates, zero React re-renders on tick.
|
||||
export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
const previewSeek = useCallback((progress: number) => {
|
||||
const s = usePlayerStore.getState();
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
pendingSeekRef.current = p;
|
||||
if (timeRef.current) {
|
||||
const previewTime = duration > 0 ? p * duration : s.currentTime;
|
||||
timeRef.current.textContent = formatTrackTime(previewTime);
|
||||
}
|
||||
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(p);
|
||||
}, [duration]);
|
||||
|
||||
const commitSeek = useCallback(() => {
|
||||
const pending = pendingSeekRef.current;
|
||||
if (pending === null) return;
|
||||
pendingSeekRef.current = null;
|
||||
seek(pending);
|
||||
}, [seek]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = getPlaybackProgressSnapshot();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return subscribePlaybackProgress(state => {
|
||||
if (isDraggingRef.current) return;
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
previewSeek(parseFloat(e.target.value));
|
||||
},
|
||||
[previewSeek]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span ref={timeRef} />
|
||||
<span>{formatTrackTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||
<div className="fs-seekbar-played" ref={playedRef} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
onMouseDown={() => { isDraggingRef.current = true; }}
|
||||
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onTouchStart={() => { isDraggingRef.current = true; }}
|
||||
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onPointerDown={() => { isDraggingRef.current = true; }}
|
||||
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onKeyUp={commitSeek}
|
||||
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
aria-label="seek"
|
||||
/>
|
||||
</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,252 @@
|
||||
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 } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
import { useFsArtistPortrait } from '../../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
|
||||
import WaveformSeek from '../WaveformSeek';
|
||||
import { FsQueueModal } from './FsQueueModal';
|
||||
import { FsLyricsApple } from './FsLyricsApple';
|
||||
import { FsPlayBtn } from './FsPlayBtn';
|
||||
import { FsClock } from './FsClock';
|
||||
import { FsTimeReadout } from './FsTimeReadout';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
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, 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 both the background
|
||||
// fallback and the foreground thumbnail: crisp instead of the old low-res tier,
|
||||
// and a single fetch/decode shared by both.
|
||||
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 resolvedCoverUrl = coverUrl;
|
||||
const thumbUrl = coverUrl;
|
||||
// Artist photo is the background; fall back to the album cover.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
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);
|
||||
|
||||
// Prefix the title with the queue position so it matches "Track x / N".
|
||||
const titlePrefix = queueLen > 0
|
||||
? `${String(queueIndex + 1).padStart(2, '0')}. `
|
||||
: '';
|
||||
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}
|
||||
>
|
||||
{/* Static sharp background — no blur, no animation */}
|
||||
{bgUrl
|
||||
? <img className="fsp-bg" src={bgUrl} alt="" aria-hidden="true" draggable={false} />
|
||||
: <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />}
|
||||
<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">{titlePrefix}{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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Clock, LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { Clock, LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
@@ -367,41 +367,6 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.fsPlayerSection')}
|
||||
icon={<Maximize2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.fsShowArtistPortrait')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.fsShowArtistPortraitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.fsShowArtistPortrait')}>
|
||||
<input type="checkbox" checked={auth.showFsArtistPortrait} onChange={e => auth.setShowFsArtistPortrait(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.fsPortraitDim')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 36, textAlign: 'right' }}>{auth.fsPortraitDim}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={auth.fsPortraitDim}
|
||||
onChange={e => auth.setFsPortraitDim(parseInt(e.target.value, 10))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.seekbarStyle')}
|
||||
icon={<Sliders size={16} />}
|
||||
|
||||
@@ -64,7 +64,6 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
|
||||
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
||||
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
||||
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
|
||||
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
|
||||
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
||||
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
||||
|
||||
Reference in New Issue
Block a user