From dc4eef1a97a5e1c83b5bf1c4dceb7e093d7fdd67 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:23:47 +0200 Subject: [PATCH] feat(fullscreen-player): rebuilt static fullscreen player (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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-` 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) --- CHANGELOG.md | 10 + src/app/AppShell.tsx | 2 +- src/components/FullscreenPlayer.test.tsx | 188 ------- src/components/FullscreenPlayer.tsx | 228 -------- src/components/fullscreenPlayer/FsArt.tsx | 54 -- src/components/fullscreenPlayer/FsClock.tsx | 35 ++ .../fullscreenPlayer/FsLyricsMenu.tsx | 77 --- .../fullscreenPlayer/FsLyricsRail.tsx | 92 ---- src/components/fullscreenPlayer/FsPlayBtn.tsx | 2 +- .../fullscreenPlayer/FsPortrait.tsx | 49 -- .../fullscreenPlayer/FsQueueModal.tsx | 81 +++ src/components/fullscreenPlayer/FsSeekbar.tsx | 89 --- .../fullscreenPlayer/FsTimeReadout.tsx | 26 + .../FullscreenPlayerStatic.tsx | 252 +++++++++ src/components/settings/AppearanceTab.tsx | 37 +- src/components/settings/settingsTabs.ts | 1 - src/cover/usePlaybackCoverArt.ts | 2 + src/hooks/useFsDynamicAccent.ts | 43 -- src/locales/de/player.ts | 6 + src/locales/en/player.ts | 6 + src/locales/es/player.ts | 6 + src/locales/fr/player.ts | 6 + src/locales/nb/player.ts | 6 + src/locales/nl/player.ts | 6 + src/locales/ro/player.ts | 6 + src/locales/ru/player.ts | 6 + src/locales/zh/player.ts | 6 + src/store/authStore.settings.test.ts | 7 +- src/store/authStore.ts | 4 - src/store/authStoreTypes.ts | 10 - src/store/authUiAppearanceActions.ts | 8 - .../components/fullscreen-player-static.css | 512 ++++++++++++++++++ src/styles/components/index.css | 1 + 33 files changed, 977 insertions(+), 887 deletions(-) delete mode 100644 src/components/FullscreenPlayer.test.tsx delete mode 100644 src/components/FullscreenPlayer.tsx delete mode 100644 src/components/fullscreenPlayer/FsArt.tsx create mode 100644 src/components/fullscreenPlayer/FsClock.tsx delete mode 100644 src/components/fullscreenPlayer/FsLyricsMenu.tsx delete mode 100644 src/components/fullscreenPlayer/FsLyricsRail.tsx delete mode 100644 src/components/fullscreenPlayer/FsPortrait.tsx create mode 100644 src/components/fullscreenPlayer/FsQueueModal.tsx delete mode 100644 src/components/fullscreenPlayer/FsSeekbar.tsx create mode 100644 src/components/fullscreenPlayer/FsTimeReadout.tsx create mode 100644 src/components/fullscreenPlayer/FullscreenPlayerStatic.tsx delete mode 100644 src/hooks/useFsDynamicAccent.ts create mode 100644 src/styles/components/fullscreen-player-static.css diff --git a/CHANGELOG.md b/CHANGELOG.md index e300a5d2..d34d48e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Dependencies — npm and Rust refresh diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 64c8a4c1..97b075b4 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -14,7 +14,7 @@ import LiveSearch from '../components/LiveSearch'; import NowPlayingDropdown from '../components/NowPlayingDropdown'; import QueuePanel from '../components/QueuePanel'; import AppRoutes from './AppRoutes'; -import FullscreenPlayer from '../components/FullscreenPlayer'; +import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic'; import ContextMenu from '../components/ContextMenu'; import SongInfoModal from '../components/SongInfoModal'; import DownloadFolderModal from '../components/DownloadFolderModal'; diff --git a/src/components/FullscreenPlayer.test.tsx b/src/components/FullscreenPlayer.test.tsx deleted file mode 100644 index de70ff87..00000000 --- a/src/components/FullscreenPlayer.test.tsx +++ /dev/null @@ -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('./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( - {}} />, - ); - expect(getByLabelText('Fullscreen Player')).toBeInTheDocument(); - }); - - it('exposes the Close Fullscreen button', () => { - usePlayerStore.setState({ currentTrack: makeTrack() }); - const { getByLabelText } = renderWithProviders( - {}} />, - ); - 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( {}} />); - - 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( {}} />); - - 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( - , - ); - 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( - {}} />, - ); - 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( - {}} />, - ); - 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( - {}} />, - ); - fireEvent.click(getByLabelText('Next Track')); - expect(nextSpy).toHaveBeenCalledTimes(1); - }); - - it('clicking Repeat cycles via toggleRepeat', () => { - usePlayerStore.setState({ currentTrack: makeTrack() }); - const { getByLabelText } = renderWithProviders( - {}} />, - ); - expect(usePlayerStore.getState().repeatMode).toBe('off'); - fireEvent.click(getByLabelText('Repeat')); - expect(usePlayerStore.getState().repeatMode).toBe('all'); - }); -}); diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx deleted file mode 100644 index fb1d356c..00000000 --- a/src/components/FullscreenPlayer.tsx +++ /dev/null @@ -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(null); - const fsControlsRef = useRef(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 ( -
- - {/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */} -