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:
Frank Stellmacher
2026-06-05 14:23:47 +02:00
committed by GitHub
parent c674e4515b
commit dc4eef1a97
33 changed files with 977 additions and 887 deletions
+10
View File
@@ -21,6 +21,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fullscreen player — rebuilt for much lower CPU/RAM
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
## Changed ## Changed
### Dependencies — npm and Rust refresh ### Dependencies — npm and Rust refresh
+1 -1
View File
@@ -14,7 +14,7 @@ import LiveSearch from '../components/LiveSearch';
import NowPlayingDropdown from '../components/NowPlayingDropdown'; import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel'; import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import FullscreenPlayer from '../components/FullscreenPlayer'; import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
import ContextMenu from '../components/ContextMenu'; import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal'; import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal'; import DownloadFolderModal from '../components/DownloadFolderModal';
-188
View File
@@ -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');
});
});
-228
View File
@@ -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>
);
}
-54
View File
@@ -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} />} : <Sunrise size={12} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span> <span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
</span> </span>
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />} ) : isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
</button> </button>
</span> </span>
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} /> <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 -36
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core'; 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 { useAuthStore } from '../../store/authStore';
import { import {
LIBRARY_GRID_MAX_COLUMNS_MAX, LIBRARY_GRID_MAX_COLUMNS_MAX,
@@ -367,41 +367,6 @@ export function AppearanceTab() {
</div> </div>
</SettingsSubSection> </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 <SettingsSubSection
title={t('settings.seekbarStyle')} title={t('settings.seekbarStyle')}
icon={<Sliders size={16} />} icon={<Sliders size={16} />}
-1
View File
@@ -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.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' }, { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' }, { 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: '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.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' }, { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
+2
View File
@@ -9,6 +9,7 @@ import { usePlayerStore } from '../store/playerStore';
export function usePlaybackCoverArt( export function usePlaybackCoverArt(
coverRef: CoverArtRef | undefined, coverRef: CoverArtRef | undefined,
displayCssPx: number, displayCssPx: number,
opts?: { fullRes?: boolean },
): CoverArtHandle { ): CoverArtHandle {
const queueServerId = usePlayerStore(s => s.queueServerId); const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queueItems.length); const queueLength = usePlayerStore(s => s.queueItems.length);
@@ -29,5 +30,6 @@ export function usePlaybackCoverArt(
); );
return useCoverArt(refWithScope, displayCssPx, { return useCoverArt(refWithScope, displayCssPx, {
surface: 'sparse', surface: 'sparse',
fullRes: opts?.fullRes,
}); });
} }
-43
View File
@@ -1,43 +0,0 @@
import { useEffect, useState } from 'react';
import { extractCoverColors } from '../utils/ui/dynamicColors';
// Module-level cache: artKey → accent color string.
// Survives track changes so same-album songs reuse the extracted color instantly.
const coverAccentCache = new Map<string, string>();
/** Extract a dominant accent color from the current cover art and cache it by
* artKey. Cache hits return instantly; cache misses fetch the cover blob,
* run extractCoverColors, then cache + apply the result. Keeps the previous
* color visible until extraction completes so the UI doesn't flash to default. */
export function useFsDynamicAccent(artUrl: string, artKey: string): string | null {
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
useEffect(() => {
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
const cached = coverAccentCache.get(artKey);
if (cached) { setDynamicAccent(cached); return; }
let cancelled = false;
let blobUrl = '';
(async () => {
try {
const resp = await fetch(artUrl);
if (cancelled) return;
const blob = await resp.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
const colors = await extractCoverColors(blobUrl);
if (cancelled) return;
if (colors.accent) {
coverAccentCache.set(artKey, colors.accent);
setDynamicAccent(colors.accent);
}
} catch { /* ignore */ } finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artKey]);
return dynamicAccent;
}
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Geschwindigkeit', playbackRate: 'Geschwindigkeit',
miniPlayer: 'Mini-Player', miniPlayer: 'Mini-Player',
lyrics: 'Lyrics', lyrics: 'Lyrics',
fsNowPlaying: 'Wird gespielt…',
fsTrackPosition: 'Titel {{current}} / {{total}}',
fsNext: 'Als Nächstes',
fsUpNext: 'Als Nächstes',
fsQueueEmpty: 'Nichts in der Warteschlange.',
shuffle: 'Zufallswiedergabe',
fsLyricsToggle: 'Lyrics im Vollbild', fsLyricsToggle: 'Lyrics im Vollbild',
lyricsLoading: 'Lyrics werden geladen…', lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Playback speed', playbackRate: 'Playback speed',
miniPlayer: 'Mini Player', miniPlayer: 'Mini Player',
lyrics: 'Lyrics', lyrics: 'Lyrics',
fsNowPlaying: 'Now playing…',
fsTrackPosition: 'Track {{current}} / {{total}}',
fsNext: 'Next',
fsUpNext: 'Up next',
fsQueueEmpty: 'Nothing queued.',
shuffle: 'Shuffle',
fsLyricsToggle: 'Lyrics in fullscreen', fsLyricsToggle: 'Lyrics in fullscreen',
lyricsLoading: 'Loading lyrics…', lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track', lyricsNotFound: 'No lyrics found for this track',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Velocidad', playbackRate: 'Velocidad',
miniPlayer: 'Mini reproductor', miniPlayer: 'Mini reproductor',
lyrics: 'Letras', lyrics: 'Letras',
fsNowPlaying: 'Reproduciendo…',
fsTrackPosition: 'Pista {{current}} / {{total}}',
fsNext: 'Siguiente',
fsUpNext: 'A continuación',
fsQueueEmpty: 'Nada en cola.',
shuffle: 'Aleatorio',
fsLyricsToggle: 'Letras en pantalla completa', fsLyricsToggle: 'Letras en pantalla completa',
lyricsLoading: 'Cargando letras…', lyricsLoading: 'Cargando letras…',
lyricsNotFound: 'No se encontraron letras para esta pista', lyricsNotFound: 'No se encontraron letras para esta pista',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Vitesse', playbackRate: 'Vitesse',
miniPlayer: 'Mini-lecteur', miniPlayer: 'Mini-lecteur',
lyrics: 'Paroles', lyrics: 'Paroles',
fsNowPlaying: "Lecture en cours…",
fsTrackPosition: "Piste {{current}} / {{total}}",
fsNext: "Suivant",
fsUpNext: "À suivre",
fsQueueEmpty: "File d'attente vide.",
shuffle: "Aléatoire",
fsLyricsToggle: 'Paroles en plein écran', fsLyricsToggle: 'Paroles en plein écran',
lyricsLoading: 'Chargement des paroles…', lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre', lyricsNotFound: 'Aucune parole trouvée pour ce titre',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Hastighet', playbackRate: 'Hastighet',
miniPlayer: 'Minispiller', miniPlayer: 'Minispiller',
lyrics: 'Sangtekst', lyrics: 'Sangtekst',
fsNowPlaying: 'Spilles nå…',
fsTrackPosition: 'Spor {{current}} / {{total}}',
fsNext: 'Neste',
fsUpNext: 'Neste',
fsQueueEmpty: 'Ingenting i køen.',
shuffle: 'Tilfeldig',
fsLyricsToggle: 'Sangtekst i fullskjerm', fsLyricsToggle: 'Sangtekst i fullskjerm',
lyricsLoading: 'Laster sangtekst…', lyricsLoading: 'Laster sangtekst…',
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet', lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Snelheid', playbackRate: 'Snelheid',
miniPlayer: 'Minispeler', miniPlayer: 'Minispeler',
lyrics: 'Songtekst', lyrics: 'Songtekst',
fsNowPlaying: 'Wordt afgespeeld…',
fsTrackPosition: 'Track {{current}} / {{total}}',
fsNext: 'Volgende',
fsUpNext: 'Hierna',
fsQueueEmpty: 'Niets in de wachtrij.',
shuffle: 'Shuffle',
fsLyricsToggle: 'Songtekst in volledig scherm', fsLyricsToggle: 'Songtekst in volledig scherm',
lyricsLoading: 'Songtekst laden…', lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer', lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Viteză', playbackRate: 'Viteză',
miniPlayer: 'Player Mini', miniPlayer: 'Player Mini',
lyrics: 'Versuri', lyrics: 'Versuri',
fsNowPlaying: 'Se redă acum…',
fsTrackPosition: 'Piesa {{current}} / {{total}}',
fsNext: 'Următoarea',
fsUpNext: 'Urmează',
fsQueueEmpty: 'Nimic în coadă.',
shuffle: 'Amestecă',
fsLyricsToggle: 'Versuri în ecran complet', fsLyricsToggle: 'Versuri în ecran complet',
lyricsLoading: 'Se încarcă versurile…', lyricsLoading: 'Se încarcă versurile…',
lyricsNotFound: 'Niciun vers găsit pentru această piesă', lyricsNotFound: 'Niciun vers găsit pentru această piesă',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Скорость', playbackRate: 'Скорость',
miniPlayer: 'Мини-плеер', miniPlayer: 'Мини-плеер',
lyrics: 'Текст', lyrics: 'Текст',
fsNowPlaying: 'Сейчас играет…',
fsTrackPosition: 'Трек {{current}} / {{total}}',
fsNext: 'Далее',
fsUpNext: 'Далее',
fsQueueEmpty: 'Очередь пуста.',
shuffle: 'Перемешать',
lyricsLoading: 'Загрузка текста…', lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден', lyricsNotFound: 'Текст не найден',
lyricsNoSources: 'Источники текста не выбраны — включите их в Настройки → Текст', lyricsNoSources: 'Источники текста не выбраны — включите их в Настройки → Текст',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: '速度', playbackRate: '速度',
miniPlayer: '迷你播放器', miniPlayer: '迷你播放器',
lyrics: '歌词', lyrics: '歌词',
fsNowPlaying: '正在播放…',
fsTrackPosition: '曲目 {{current}} / {{total}}',
fsNext: '下一首',
fsUpNext: '即将播放',
fsQueueEmpty: '队列为空。',
shuffle: '随机播放',
fsLyricsToggle: '全屏歌词', fsLyricsToggle: '全屏歌词',
lyricsLoading: '正在加载歌词…', lyricsLoading: '正在加载歌词…',
lyricsNotFound: '未找到此曲目的歌词', lyricsNotFound: '未找到此曲目的歌词',
+1 -6
View File
@@ -63,7 +63,6 @@ describe('trivial pass-through setters', () => {
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false], ['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'], ['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
['setNowPlayingEnabled', 'nowPlayingEnabled', true], ['setNowPlayingEnabled', 'nowPlayingEnabled', true],
['setShowFullscreenLyrics', 'showFullscreenLyrics', false],
['setLyricsStaticOnly', 'lyricsStaticOnly', true], ['setLyricsStaticOnly', 'lyricsStaticOnly', true],
['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false], ['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false],
['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true], ['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true],
@@ -84,7 +83,6 @@ describe('trivial pass-through setters', () => {
['setHotCacheMaxMb', 'hotCacheMaxMb', 1024], ['setHotCacheMaxMb', 'hotCacheMaxMb', 1024],
['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60], ['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60],
['setPreloadCustomSeconds', 'preloadCustomSeconds', 45], ['setPreloadCustomSeconds', 'preloadCustomSeconds', 45],
['setFsPortraitDim', 'fsPortraitDim', 64],
])('%s stores a numeric value', (setter, key, value) => { ])('%s stores a numeric value', (setter, key, value) => {
(useAuthStore.getState() as unknown as Record<string, (v: unknown) => void>)[setter](value); (useAuthStore.getState() as unknown as Record<string, (v: unknown) => void>)[setter](value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value); expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value);
@@ -295,15 +293,12 @@ describe('lyrics source setters', () => {
expect(useAuthStore.getState().lyricsSources).toEqual(sources); expect(useAuthStore.getState().lyricsSources).toEqual(sources);
}); });
it('setYouLyPlusEnabled + setFsLyricsStyle + setSidebarLyricsStyle write values through', () => { it('setYouLyPlusEnabled + setSidebarLyricsStyle write values through', () => {
useAuthStore.getState().setYouLyPlusEnabled(true); useAuthStore.getState().setYouLyPlusEnabled(true);
expect(useAuthStore.getState().youLyPlusEnabled).toBe(true); expect(useAuthStore.getState().youLyPlusEnabled).toBe(true);
useAuthStore.getState().setYouLyPlusEnabled(false); useAuthStore.getState().setYouLyPlusEnabled(false);
expect(useAuthStore.getState().youLyPlusEnabled).toBe(false); expect(useAuthStore.getState().youLyPlusEnabled).toBe(false);
useAuthStore.getState().setFsLyricsStyle('apple');
expect(useAuthStore.getState().fsLyricsStyle).toBe('apple');
useAuthStore.getState().setSidebarLyricsStyle('apple'); useAuthStore.getState().setSidebarLyricsStyle('apple');
expect(useAuthStore.getState().sidebarLyricsStyle).toBe('apple'); expect(useAuthStore.getState().sidebarLyricsStyle).toBe('apple');
}); });
-4
View File
@@ -88,11 +88,7 @@ export const useAuthStore = create<AuthState>()(
lyricsSources: DEFAULT_LYRICS_SOURCES, lyricsSources: DEFAULT_LYRICS_SOURCES,
youLyPlusEnabled: false, youLyPlusEnabled: false,
lyricsStaticOnly: false, lyricsStaticOnly: false,
showFullscreenLyrics: true,
fsLyricsStyle: 'rail',
sidebarLyricsStyle: 'classic', sidebarLyricsStyle: 'classic',
showFsArtistPortrait: true,
fsPortraitDim: 28,
showChangelogOnUpdate: true, showChangelogOnUpdate: true,
lastSeenChangelogVersion: '', lastSeenChangelogVersion: '',
advancedSettingsEnabled: false, advancedSettingsEnabled: false,
-10
View File
@@ -173,14 +173,8 @@ export interface AuthState {
* Honoured in both lyrics modes. * Honoured in both lyrics modes.
*/ */
lyricsStaticOnly: boolean; lyricsStaticOnly: boolean;
showFullscreenLyrics: boolean;
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
fsLyricsStyle: 'rail' | 'apple';
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */ /** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
sidebarLyricsStyle: 'classic' | 'apple'; sidebarLyricsStyle: 'classic' | 'apple';
showFsArtistPortrait: boolean;
/** Portrait dimming 0100 (percent), applied as CSS rgba alpha */
fsPortraitDim: number;
showChangelogOnUpdate: boolean; showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string; lastSeenChangelogVersion: string;
/** Reveals sub-sections marked `advanced` across all Settings tabs. */ /** Reveals sub-sections marked `advanced` across all Settings tabs. */
@@ -340,11 +334,7 @@ export interface AuthState {
setLyricsSources: (sources: LyricsSourceConfig[]) => void; setLyricsSources: (sources: LyricsSourceConfig[]) => void;
setYouLyPlusEnabled: (v: boolean) => void; setYouLyPlusEnabled: (v: boolean) => void;
setLyricsStaticOnly: (v: boolean) => void; setLyricsStaticOnly: (v: boolean) => void;
setShowFullscreenLyrics: (v: boolean) => void;
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void; setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
setShowFsArtistPortrait: (v: boolean) => void;
setFsPortraitDim: (v: number) => void;
setShowChangelogOnUpdate: (v: boolean) => void; setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void; setLastSeenChangelogVersion: (v: string) => void;
setAdvancedSettingsEnabled: (v: boolean) => void; setAdvancedSettingsEnabled: (v: boolean) => void;
-8
View File
@@ -27,11 +27,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setQueueNowPlayingCollapsed' | 'setQueueNowPlayingCollapsed'
| 'setQueueDurationDisplayMode' | 'setQueueDurationDisplayMode'
| 'setQueueDisplayMode' | 'setQueueDisplayMode'
| 'setShowFullscreenLyrics'
| 'setFsLyricsStyle'
| 'setSidebarLyricsStyle' | 'setSidebarLyricsStyle'
| 'setShowFsArtistPortrait'
| 'setFsPortraitDim'
| 'setShowChangelogOnUpdate' | 'setShowChangelogOnUpdate'
| 'setLastSeenChangelogVersion' | 'setLastSeenChangelogVersion'
| 'setAdvancedSettingsEnabled' | 'setAdvancedSettingsEnabled'
@@ -52,11 +48,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }), setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }),
setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }), setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }),
setQueueDisplayMode: (v) => set({ queueDisplayMode: v }), setQueueDisplayMode: (v) => set({ queueDisplayMode: v }),
setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }),
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }), setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
setShowFsArtistPortrait: (v) => set({ showFsArtistPortrait: v }),
setFsPortraitDim: (v) => set({ fsPortraitDim: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }), setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
@@ -0,0 +1,512 @@
/* Static fullscreen player (lean, media-center layout)
No backdrop-filter, no blur, no infinite animations. Background is a sharp
image; the darkening gradient + vignette are plain static overlays. Only the
seekbar and clock update at runtime, and they own their state. */
.fsp {
position: fixed;
inset: 0;
z-index: 1000;
background: #06060e;
color: #fff;
overflow: hidden;
user-select: none;
--fsp-cover-size: clamp(150px, 24vh, 250px);
}
.fsp[data-idle='true'] {
cursor: none;
}
/* ── Background ── */
.fsp-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* Bias toward the upper third so faces survive a 16:9 crop of square art. */
object-position: center 30%;
}
.fsp-bg--empty {
background: radial-gradient(circle at 50% 35%, #1a1a2e, #06060e 70%);
}
/* Static text-contrast scrim + edge vignette — baked look, no runtime effect. */
.fsp-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(to top, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 38%),
linear-gradient(to right, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 55%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 22%);
}
.fsp-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse 75% 75% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
}
/* ── Top bar ── */
.fsp-top {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 3;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
padding-right: calc(var(--space-6) + 52px); /* leave room for the close button */
}
.fsp-nowplaying {
display: flex;
flex-direction: column;
gap: 2px;
}
.fsp-nowplaying-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
}
.fsp-nowplaying-pos {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.fsp-clock {
font-size: 15px;
color: rgba(255, 255, 255, 0.92);
font-variant-numeric: tabular-nums;
}
/* ── Close (fades when idle) ── */
.fsp-close {
position: absolute;
top: var(--space-4);
right: var(--space-4);
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-base), background var(--transition-fast), color var(--transition-fast);
}
.fsp[data-idle='false'] .fsp-close,
.fsp:hover .fsp-close {
opacity: 1;
}
.fsp-close:hover {
background: rgba(0, 0, 0, 0.6);
color: #fff;
}
/* ── Bottom cluster ── */
.fsp-foot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: 0 var(--space-6) var(--space-5);
/* Soft backing for the control row only — the text has its own hard bar. */
background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.18) 26%, rgba(0, 0, 0, 0) 52%);
}
/* The black bar: hugs the text + cover with equal padding above and below.
The cover is bottom-aligned to the text and pokes above the bar's top. */
.fsp-info-row {
position: relative;
min-width: 0;
/* Span the full width (cancel the foot's side padding); text stays inset. */
margin: 0 calc(-1 * var(--space-6));
padding: var(--space-4) var(--space-6);
background: rgba(0, 0, 0, 0.5);
}
/* Big cover, bottom-flush with the text block; its top pokes above the bar. */
.fsp-cover {
position: absolute;
left: var(--space-6);
bottom: var(--space-4);
z-index: 1;
width: var(--fsp-cover-size);
height: var(--fsp-cover-size);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.75);
}
.fsp-cover-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fsp-cover-img--empty {
background: var(--bg-hover);
}
.fsp-info-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
/* Clear the overlapping cover on the left. */
padding-left: calc(var(--fsp-cover-size) + var(--space-5));
}
.fsp-title {
margin: 0;
font-size: 46px;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7);
}
.fsp-artist {
margin: 0;
font-size: 25px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
.fsp-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
color: rgba(255, 255, 255, 0.78);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.fsp-meta-dot {
opacity: 0.55;
}
.fsp-stars {
display: inline-flex;
gap: 2px;
color: var(--accent);
}
.fsp-star-btn {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
line-height: 0;
transition: transform 80ms ease;
}
.fsp-star-btn:hover {
transform: scale(1.15);
}
.fsp-next {
margin: 3px 0 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.65);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
/* ── Controls row: transport · time · actions ── */
.fsp-controls {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-4);
/* Own semi-transparent bar, a bit darker than the info bar above for contrast.
Full-width: cancel the foot's side padding like .fsp-info-row. Negative top
margin cancels the foot's flex gap so this bar touches the info bar above
(the gap to the waveform below stays). */
margin: calc(-1 * var(--space-4)) calc(-1 * var(--space-6)) 0;
padding: var(--space-3) var(--space-6);
background: rgba(0, 0, 0, 0.88);
}
.fsp-transport {
display: flex;
align-items: center;
gap: var(--space-2);
justify-self: start;
}
.fsp-actions {
display: flex;
align-items: center;
gap: var(--space-2);
justify-self: end;
}
.fsp-time {
justify-self: center;
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
white-space: nowrap;
}
.fsp-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.fsp-btn-sm {
width: 38px;
height: 38px;
color: rgba(255, 255, 255, 0.6);
}
.fsp-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.fsp-btn.active {
color: var(--accent);
}
/* Bottom-right action cluster: larger tap targets than the transport's small buttons. */
.fsp-actions .fsp-btn-sm {
width: 46px;
height: 46px;
}
/* Reused FsPlayBtn (renders .fs-btn.fs-btn-play) — scope its look to this player. */
.fsp .playback-transport-play-wrap {
position: relative;
display: inline-flex;
}
/* Match the plain transport buttons — no white circle. */
.fsp .fs-btn-play {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: 0;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.fsp .fs-btn-play:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
/* True waveform seekbar (WaveformSeek) full-width media-center bar
Replaces the old thin FsSeekbar. Time stays centered in the control row
(FsTimeReadout). The taller canvas makes the foot grow upward, shifting the
info/control rows up. */
.fsp .waveform-seek-container {
width: 100%;
}
/* WaveformSeek hardcodes a 24px canvas inline for the player bar; override it
here for the fullscreen view. Its ResizeObserver redraws at the new size. */
.fsp .waveform-seek-container canvas {
height: clamp(32px, 5vh, 52px) !important;
}
/* "Up next" overlay (FsQueueModal) semi-transparent popover anchored
bottom-right at the queue button, no blur. Transparent backdrop just catches
the outside click to close. */
.fsq-backdrop {
position: absolute;
inset: 0;
z-index: 5;
background: transparent;
}
.fsq-panel {
position: absolute;
right: var(--space-6);
bottom: 163px; /* flush with the top of the black info frame */
width: min(420px, 40%);
max-height: 62vh;
display: flex;
flex-direction: column;
background: rgba(18, 18, 22, 0.82);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.fsq-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.fsq-title {
font-size: 15px;
font-weight: 600;
color: #fff;
}
.fsq-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.fsq-close:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.fsq-list {
overflow-y: auto;
padding: var(--space-2) 0;
}
.fsq-empty {
padding: var(--space-5);
text-align: center;
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
}
.fsq-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-5);
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
text-align: left;
}
.fsq-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.fsq-item-pos {
width: 24px;
flex-shrink: 0;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.fsq-item-info {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.fsq-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.fsq-item-artist {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.fsq-item-dur {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* Scrolling lyrics overlay (reuses FsLyricsApple) semi-transparent, no blur.
FsLyricsApple's .fsa-lyrics-container is position:absolute inset:0, so it fills
this box. */
.fsp-lyrics-overlay {
position: absolute;
top: var(--space-6);
left: 14%;
right: 14%;
bottom: 360px; /* ends above the song-info / cover block; tune */
z-index: 4;
background: rgba(0, 0, 0, 0.88);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Centered lyrics text in the fullscreen overlay (FsLyricsApple defaults to
left-aligned for the old layout). */
.fsp-lyrics-overlay .fsa-lyrics-container {
text-align: center;
}
.fsp-lyrics-overlay .fsa-lyric-line {
text-align: center;
transform-origin: center center;
}
+1
View File
@@ -30,6 +30,7 @@
@import './login-page.css'; @import './login-page.css';
@import './loading-empty.css'; @import './loading-empty.css';
@import './fullscreen-player-adaptive-portrait.css'; @import './fullscreen-player-adaptive-portrait.css';
@import './fullscreen-player-static.css';
@import './context-menu.css'; @import './context-menu.css';
@import './css-tooltips.css'; @import './css-tooltips.css';
@import './playlists-page.css'; @import './playlists-page.css';