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) */} -