mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(fullscreenPlayer): selectable Minimal and Immersive fullscreen player styles (#1249)
* feat(fullscreenPlayer): recover immersive player — components, hooks, CSS, settings
Phase 1 of the Minimal/Immersive style toggle. Recover the pre-#1001 fullscreen
player from history into the feature folder and make it compile against the
current architecture:
- FullscreenPlayerImmersive + Fs{Art,Portrait,Seekbar,LyricsMenu,LyricsRail}
- hooks useFsDynamicAccent, useFsArtistPortrait
- fullscreen-player-immersive.css (core/mesh/portrait/controls/seekbar + rail +
lyrics-menu + no-compositing overrides), shared Apple-lyrics stays in
adaptive-portrait.css
- re-add settings removed in #1001: showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim; plus the new fullscreenPlayerStyle toggle
Not wired into the shell yet (Phase 2). The two hooks still trip the current
set-state-in-effect lint rule — both are reworked in Phase 3 (portrait rewire to
artistBackdrop + dynamic-accent perf gating), which resolves it.
* feat(fullscreenPlayer): style toggle — route Minimal/Immersive + Appearance picker (13 locales)
* feat(fullscreenPlayer): immersive artist portrait via fanart backdrop pipeline; fix hook lint
* refactor(playback): add feature barrel; route immersive imports through barrels (dep:check)
* fix(fullscreenPlayer): restore lyrics-style i18n (13 locales) + close popover on style pick
* feat(fullscreenPlayer): immersive Apple-lyrics mode shows artist image as dimmed full-screen backdrop
* fix(fullscreenPlayer): address code-review findings on the immersive player
- star: pass currentTrack.serverId to queueSongStar (multi-server correctness)
- cover: use album-keyed ref (useAlbumCoverRef) to stop per-track cover flicker
- backdrop: honour backdrops.fullscreenPlayer.enabled (toggle was ignored)
- lyrics popover: Escape now closes only the popover, not the whole player
(capture-phase listener + stopPropagation vs useFsIdleFade's bubble handler)
- settings: recover the Show-artist-photo toggle + photo-dim slider (13 locales),
shown for the immersive style — the persisted settings had no UI
- apple mode: don't mount the (CSS-hidden) portrait — the full-screen backdrop
already shows the image; avoids a duplicate 2000px load/decode per track
- dynamic accent: cache-as-source-of-truth + last-shown ref so a cache-hit album
no longer surfaces a stale accent from an earlier album
- rehydrate: clamp fsPortraitDim (0–80) so a malformed value can't yield NaN dim
- FsArt: clear the layer when a track has no cover (was leaving prior art)
- FsSeekbar: pause the progress subscription during keyboard seeking (onKeyDown)
* fix(fullscreenPlayer): stop the immersive scrim over-darkening the artist portrait in rail mode
* fix(fullscreenPlayer): dynamic cover accent — re-run extraction when the async cover src resolves
* refactor(fullscreenPlayer): dynamic accent reads the cover blob via the cover cache, not a raw fetch
* test(fullscreenPlayer): unit-test dynamic accent hook + immersive player render/control smoke
* docs(changelog): note fullscreen player styles + credits (#1249)
* fix(fullscreenPlayer): satisfy npm run lint — drop no-explicit-any casts and ref-in-render
This commit is contained in:
@@ -68,6 +68,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published.
|
||||
* The theme card **What's new** now shows just the latest version's notes instead of the full version history.
|
||||
|
||||
### Fullscreen player — Minimal and Immersive styles
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1249](https://github.com/Psychotoxical/psysonic/pull/1249)**
|
||||
|
||||
* **Settings → Appearance → Fullscreen player style** lets you choose **Minimal** (the current view) or **Immersive** — the earlier fullscreen player, with the artist photo/backdrop, a cover-derived accent colour, and rail or Apple-style scrolling lyrics.
|
||||
* In Immersive, **Show artist photo** and **Photo dimming** are configurable; Apple-style lyrics show the artist image as a dimmed full-screen backdrop.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import DevNetworkModeToggle from '@/app/DevNetworkModeToggle';
|
||||
import { NowPlayingDropdown } from '@/features/nowPlaying';
|
||||
import QueuePanel from '@/features/queue';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import FullscreenPlayer from '@/features/fullscreenPlayer';
|
||||
import FullscreenPlayer, { FullscreenPlayerImmersive } from '@/features/fullscreenPlayer';
|
||||
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
|
||||
import SongInfoModal from '@/features/playback/components/SongInfoModal';
|
||||
import { DownloadFolderModal } from '@/features/offline';
|
||||
@@ -114,6 +114,7 @@ export function AppShell() {
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const fullscreenPlayerStyle = useAuthStore(s => s.fullscreenPlayerStyle);
|
||||
const offlineCtx = useReactiveOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
@@ -338,7 +339,9 @@ export function AppShell() {
|
||||
{isMobile && !isMobilePlayer && <BottomNav />}
|
||||
{!isMobilePlayer && <PlayerBar />}
|
||||
{isFullscreenOpen && (
|
||||
<FullscreenPlayer onClose={toggleFullscreen} />
|
||||
fullscreenPlayerStyle === 'immersive'
|
||||
? <FullscreenPlayerImmersive onClose={toggleFullscreen} />
|
||||
: <FullscreenPlayer onClose={toggleFullscreen} />
|
||||
)}
|
||||
<ContextMenu />
|
||||
<SongInfoModal />
|
||||
|
||||
@@ -409,6 +409,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)',
|
||||
'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)',
|
||||
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
|
||||
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { useCachedUrl } from '@/ui/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) {
|
||||
// New track has no cover → drop the old layer so the placeholder shows,
|
||||
// instead of leaving the previous track's art on screen.
|
||||
setLayers(prev => (prev.length ? [] : prev));
|
||||
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,84 @@
|
||||
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;
|
||||
// Capture phase + stopPropagation so Escape closes only the popover, not the
|
||||
// whole player: the player's own Escape handler (useFsIdleFade) listens in the
|
||||
// bubble phase, so stopping propagation here keeps it from firing too.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopPropagation();
|
||||
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, true);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
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); onClose(); }}
|
||||
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)}`)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLyrics, type WordLyricsLine, useWordLyricsSync } from '@/features/lyrics';
|
||||
import type { LrcLine } from '@/features/lyrics';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
|
||||
// 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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,89 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore, getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback';
|
||||
import { formatTrackTime } from '@/lib/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(); }}
|
||||
onKeyDown={() => { isDraggingRef.current = true; }}
|
||||
onKeyUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/ui/CachedImage', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/ui/CachedImage')>('@/ui/CachedImage');
|
||||
return { ...actual, useCachedUrl: vi.fn((url: string) => (url ? `mock://${url}` : '')) };
|
||||
});
|
||||
|
||||
import FullscreenPlayerImmersive from './FullscreenPlayerImmersive';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
useAuthStore.setState({ fullscreenPlayerStyle: 'immersive' });
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
});
|
||||
|
||||
describe('FullscreenPlayerImmersive', () => {
|
||||
it('renders the labelled fullscreen dialog and close button', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(<FullscreenPlayerImmersive onClose={() => {}} />);
|
||||
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
|
||||
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking Close calls the onClose prop', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const onClose = vi.fn();
|
||||
const { getByLabelText } = renderWithProviders(<FullscreenPlayerImmersive onClose={onClose} />);
|
||||
fireEvent.click(getByLabelText('Close Fullscreen'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Stop calls the player store stop()', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
|
||||
const { getByLabelText } = renderWithProviders(<FullscreenPlayerImmersive onClose={() => {}} />);
|
||||
fireEvent.click(getByLabelText('Stop'));
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { queueSongStar, playbackCoverArtForAlbum, usePlayerStore } from '@/features/playback';
|
||||
import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
|
||||
import { useAlbumCoverRef, useArtistCoverRef } from '@/cover/useLibraryCoverRef';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { useCachedUrl } from '@/ui/CachedImage';
|
||||
import { getCachedBlob } from '@/cover/imageCache';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useArtistFanart } from '@/cover/useArtistFanart';
|
||||
import { backdropFromConfig } from '@/cover/artistBackdrop';
|
||||
import { FsLyricsApple } from './FsLyricsApple';
|
||||
import { FsLyricsRail } from './FsLyricsRail';
|
||||
import { FsArt } from './FsArt';
|
||||
import { FsPortrait } from './FsPortrait';
|
||||
import { FsSeekbar } from './FsSeekbar';
|
||||
import { FsLyricsMenu } from './FsLyricsMenu';
|
||||
import { FsPlayBtn } from './FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '@/features/fullscreenPlayer/hooks/useFsDynamicAccent';
|
||||
import { useFsIdleFade } from '@/features/fullscreenPlayer/hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '@/features/queue';
|
||||
|
||||
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.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Album-keyed cover ref so the cover stays stable across track changes within
|
||||
// the same album — a per-track ref re-keys on `mf-<trackId>` coverArt and
|
||||
// reloads/flickers the cover on every song advance (same fix as the Minimal player).
|
||||
const playbackCoverRef =
|
||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? 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. Resolved through the shared backdrop
|
||||
// pipeline (banner / fanart.tv / Navidrome artist cover, in the user's
|
||||
// configured fullscreen-player source order) — the same source the Minimal
|
||||
// player uses. Falls back to the album cover when nothing resolves.
|
||||
const fsBackdropCfg = useThemeStore(s => s.backdrops.fullscreenPlayer);
|
||||
const fanart = useArtistFanart(currentTrack?.artistId, {
|
||||
artistName: currentTrack?.artist,
|
||||
albumTitle: currentTrack?.album,
|
||||
});
|
||||
const artistCoverRef =
|
||||
useArtistCoverRef(currentTrack?.artistId, undefined, undefined, { libraryResolve: false }) ??
|
||||
undefined;
|
||||
const artistImage = usePlaybackCoverArt(artistCoverRef, 2000, { fullRes: true });
|
||||
const artistImgUrl = useCachedUrl(artistImage.src, artistImage.cacheKey, true);
|
||||
const artistBgUrl = fsBackdropCfg.enabled
|
||||
? backdropFromConfig(fsBackdropCfg.sources, { fanart, navidrome: artistImgUrl }).url
|
||||
: '';
|
||||
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>
|
||||
|
||||
{/* Apple/scrolling lyrics fill the width and hide the right-half portrait,
|
||||
so the artist image renders as a dimmed full-screen backdrop instead. */}
|
||||
{isAppleMode && showFsArtistPortrait && portraitUrl && (
|
||||
<div
|
||||
className="fs-apple-backdrop"
|
||||
style={{ backgroundImage: `url("${portraitUrl}")` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Layer 1 — artist portrait, right half. Not mounted in Apple mode: the
|
||||
full-screen backdrop above already shows the image, so rendering the
|
||||
(CSS-hidden) portrait too would load/decode the same image twice. */}
|
||||
{showFsArtistPortrait && !isAppleMode && <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={t('player.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/cover/imageCache', () => ({ getCachedBlob: vi.fn() }));
|
||||
vi.mock('@/lib/dom/dynamicColors', () => ({ extractCoverColors: vi.fn() }));
|
||||
|
||||
import { getCachedBlob } from '@/cover/imageCache';
|
||||
import { extractCoverColors } from '@/lib/dom/dynamicColors';
|
||||
import { useFsDynamicAccent } from './useFsDynamicAccent';
|
||||
|
||||
const blob = () => new Blob(['x'], { type: 'image/webp' });
|
||||
const accent = (hex: string) => vi.mocked(extractCoverColors).mockResolvedValue({ accent: hex } as never);
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom has no object-URL support.
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:mock');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('useFsDynamicAccent', () => {
|
||||
it('returns null and does no work when there is no cover art', () => {
|
||||
const { result } = renderHook(() => useFsDynamicAccent('', ''));
|
||||
expect(result.current).toBeNull();
|
||||
expect(getCachedBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the accent from the cover on a cache miss', async () => {
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob());
|
||||
accent('#abcdef');
|
||||
const { result } = renderHook(() => useFsDynamicAccent('http://x/cover', 'key-A'));
|
||||
await waitFor(() => expect(result.current).toBe('#abcdef'));
|
||||
expect(getCachedBlob).toHaveBeenCalledWith('http://x/cover', 'key-A');
|
||||
});
|
||||
|
||||
it('re-runs extraction when the cover src resolves AFTER the cacheKey (async-src fix)', async () => {
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob());
|
||||
accent('#112233');
|
||||
// First render: cacheKey is set but src is still empty → nothing extracted.
|
||||
const { result, rerender } = renderHook(
|
||||
({ url, key }: { url: string; key: string }) => useFsDynamicAccent(url, key),
|
||||
{ initialProps: { url: '', key: 'key-B' } },
|
||||
);
|
||||
expect(result.current).toBeNull();
|
||||
expect(getCachedBlob).not.toHaveBeenCalled();
|
||||
// The async src arrives on a later render (same key) → extraction must fire.
|
||||
rerender({ url: 'http://x/cover', key: 'key-B' });
|
||||
await waitFor(() => expect(result.current).toBe('#112233'));
|
||||
});
|
||||
|
||||
it('serves a same-key remount from the module cache without re-fetching', async () => {
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob());
|
||||
accent('#445566');
|
||||
const first = renderHook(() => useFsDynamicAccent('http://x/c', 'key-C'));
|
||||
await waitFor(() => expect(first.result.current).toBe('#445566'));
|
||||
vi.mocked(getCachedBlob).mockClear();
|
||||
const second = renderHook(() => useFsDynamicAccent('http://x/c', 'key-C'));
|
||||
expect(second.result.current).toBe('#445566');
|
||||
expect(getCachedBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the accent unset when extraction yields no colour', async () => {
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob());
|
||||
vi.mocked(extractCoverColors).mockResolvedValue({ accent: undefined } as never);
|
||||
const { result } = renderHook(() => useFsDynamicAccent('http://x/d', 'key-D'));
|
||||
await waitFor(() => expect(getCachedBlob).toHaveBeenCalled());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { extractCoverColors } from '@/lib/dom/dynamicColors';
|
||||
import { getCachedBlob } from '@/cover/imageCache';
|
||||
|
||||
// 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 resolve synchronously during render (same-album songs are
|
||||
* instant); a miss fetches the cached cover blob, runs extractCoverColors,
|
||||
* writes the cache and forces a re-render. */
|
||||
export function useFsDynamicAccent(artUrl: string, artKey: string): string | null {
|
||||
// The module cache is the source of truth — the async callback writes it, so a
|
||||
// completed extraction shows up here on the next render. `bump` only forces
|
||||
// that re-render (no synchronous setState in the effect body).
|
||||
const cached = artKey && artUrl ? coverAccentCache.get(artKey) ?? null : null;
|
||||
const [, bump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artKey || !artUrl || coverAccentCache.has(artKey)) return;
|
||||
let cancelled = false;
|
||||
let blobUrl = '';
|
||||
(async () => {
|
||||
try {
|
||||
// Route through the cover cache (mem + IDB) rather than a raw fetch —
|
||||
// the cover is already cached by FsArt, so this is usually a cache hit.
|
||||
const blob = await getCachedBlob(artUrl, artKey);
|
||||
if (cancelled || !blob) return;
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
const colors = await extractCoverColors(blobUrl);
|
||||
if (cancelled) return;
|
||||
if (colors.accent) {
|
||||
coverAccentCache.set(artKey, colors.accent);
|
||||
bump(n => n + 1);
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// artUrl is a dep too: usePlaybackCoverArt yields the cacheKey synchronously
|
||||
// but the src asynchronously, so keying only on artKey would fire this effect
|
||||
// once with an empty artUrl and never retry. The has(artKey) guard keeps a
|
||||
// later src rotation from re-extracting an already-cached cover.
|
||||
}, [artKey, artUrl]);
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -7,3 +7,4 @@
|
||||
* the OS window's fullscreen state (app-shell concern), not this player view.
|
||||
*/
|
||||
export { default } from './components/FullscreenPlayerStatic';
|
||||
export { default as FullscreenPlayerImmersive } from './components/FullscreenPlayerImmersive';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Playback feature public surface. Cross-feature consumers import from here
|
||||
* (not deep module paths) so the dependency-cruiser layering guard stays green.
|
||||
* Kept intentionally small — extend as other features need a symbol.
|
||||
*/
|
||||
export { usePlayerStore } from './store/playerStore';
|
||||
export { queueSongStar } from './store/pendingStarSync';
|
||||
export { getPlaybackProgressSnapshot, subscribePlaybackProgress } from './store/playbackProgress';
|
||||
export { playbackCoverArtForAlbum } from './utils/playback/playbackServer';
|
||||
@@ -26,6 +26,11 @@ export function AppearanceTab() {
|
||||
const fontStore = useFontStore();
|
||||
const [isTilingWm, setIsTilingWm] = useState(false);
|
||||
|
||||
const fullscreenPlayerStyleOptions: SegmentedOption<'minimal' | 'immersive'>[] = [
|
||||
{ id: 'minimal', label: t('settings.fullscreenPlayerMinimal') },
|
||||
{ id: 'immersive', label: t('settings.fullscreenPlayerImmersive') },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
isTilingWmCmd().then(setIsTilingWm).catch(() => {});
|
||||
@@ -269,6 +274,52 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.fullscreenPlayerStyle')}
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<SettingsField desc={t('settings.fullscreenPlayerStyleDesc')}>
|
||||
<SettingsSegmented
|
||||
options={fullscreenPlayerStyleOptions}
|
||||
value={auth.fullscreenPlayerStyle}
|
||||
onChange={auth.setFullscreenPlayerStyle}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsGroup>
|
||||
{auth.fullscreenPlayerStyle === 'immersive' && (
|
||||
<SettingsGroup>
|
||||
<SettingsToggle
|
||||
label={t('settings.fsShowArtistPortrait')}
|
||||
desc={t('settings.fsShowArtistPortraitDesc')}
|
||||
checked={auth.showFsArtistPortrait}
|
||||
onChange={auth.setShowFsArtistPortrait}
|
||||
/>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<SettingsSubCard>
|
||||
<SettingsField label={t('settings.fsPortraitDim')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<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"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<SettingsValue>{auth.fsPortraitDim}%</SettingsValue>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.seekbarStyle')}
|
||||
icon={<Sliders size={16} />}
|
||||
|
||||
@@ -720,6 +720,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Като Apple Music',
|
||||
sidebarLyricsStyleAppleDesc: 'Активният ред се закача близо до върха с плавни пружинни преходи.',
|
||||
seekbarStyle: 'Стил на лентата за превъртане',
|
||||
fullscreenPlayerStyle: 'Стил на плейъра на цял екран',
|
||||
fsLyricsStyleRail: 'Релса',
|
||||
fsLyricsStyleRailDesc: 'Класически плъзгащ се ред от 5 линии.',
|
||||
fsLyricsStyleApple: 'Превъртане',
|
||||
fsLyricsStyleAppleDesc: 'Списък с превъртане на цял екран.',
|
||||
fullscreenPlayerStyleDesc: 'Изберете как да изглежда изгледът за възпроизвеждане на цял екран.',
|
||||
fullscreenPlayerMinimal: 'Минимален',
|
||||
fullscreenPlayerImmersive: 'Завладяващ',
|
||||
fsShowArtistPortrait: 'Показвай снимка на изпълнителя',
|
||||
fsShowArtistPortraitDesc: 'Показвай снимката на изпълнителя (или обложката на албума) от дясната страна на плейъра на цял екран.',
|
||||
fsPortraitDim: 'Затъмняване на снимката',
|
||||
seekbarStyleDesc: 'Избери вида на лентата за превъртане на плейъра',
|
||||
buttonSizeTitle: 'Компактни бутони',
|
||||
buttonSizeLabel: 'Размер на бутоните',
|
||||
|
||||
@@ -653,6 +653,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
fullscreenPlayerStyle: 'Vollbild-Player-Stil',
|
||||
fsLyricsStyleRail: 'Schiene',
|
||||
fsLyricsStyleRailDesc: 'Klassische 5-Zeilen-Schiene.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Vollbild-Scrollliste.',
|
||||
fullscreenPlayerStyleDesc: 'Wähle, wie die Vollbild-Wiedergabeansicht aussieht.',
|
||||
fullscreenPlayerMinimal: 'Minimal',
|
||||
fullscreenPlayerImmersive: 'Immersiv',
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
buttonSizeTitle: 'Kompakte Buttons',
|
||||
buttonSizeLabel: 'Buttongröße',
|
||||
|
||||
@@ -720,6 +720,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
|
||||
seekbarStyle: 'Seekbar Style',
|
||||
fullscreenPlayerStyle: 'Fullscreen player style',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Classic 5-line sliding rail.',
|
||||
fsLyricsStyleApple: 'Scrolling',
|
||||
fsLyricsStyleAppleDesc: 'Full-screen scrollable list.',
|
||||
fullscreenPlayerStyleDesc: 'Choose how the fullscreen now-playing view looks.',
|
||||
fullscreenPlayerMinimal: 'Minimal',
|
||||
fullscreenPlayerImmersive: 'Immersive',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||
buttonSizeTitle: 'Compact buttons',
|
||||
buttonSizeLabel: 'Button size',
|
||||
|
||||
@@ -652,6 +652,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
||||
seekbarStyle: 'Estilo de Barra de Progreso',
|
||||
fullscreenPlayerStyle: 'Estilo del reproductor a pantalla completa',
|
||||
fsLyricsStyleRail: 'Carril',
|
||||
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
|
||||
fsLyricsStyleApple: 'Desplazamiento',
|
||||
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
|
||||
fullscreenPlayerStyleDesc: 'Elige el aspecto de la vista de reproducción a pantalla completa.',
|
||||
fullscreenPlayerMinimal: 'Minimalista',
|
||||
fullscreenPlayerImmersive: 'Inmersivo',
|
||||
fsShowArtistPortrait: 'Mostrar foto del artista',
|
||||
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
|
||||
fsPortraitDim: 'Oscurecimiento de foto',
|
||||
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
||||
buttonSizeTitle: 'Botones compactos',
|
||||
buttonSizeLabel: 'Tamaño de los botones',
|
||||
|
||||
@@ -640,6 +640,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: "Apple Music-like",
|
||||
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
fullscreenPlayerStyle: 'Style du lecteur plein écran',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Rail glissant classique de 5 lignes.',
|
||||
fsLyricsStyleApple: 'Défilement',
|
||||
fsLyricsStyleAppleDesc: 'Liste déroulante plein écran.',
|
||||
fullscreenPlayerStyleDesc: 'Choisissez l’apparence de la vue plein écran en cours de lecture.',
|
||||
fullscreenPlayerMinimal: 'Minimal',
|
||||
fullscreenPlayerImmersive: 'Immersif',
|
||||
fsShowArtistPortrait: 'Afficher la photo de l\'artiste',
|
||||
fsShowArtistPortraitDesc: 'Afficher la photo de l\'artiste (ou la pochette) sur le côté droit du lecteur plein écran.',
|
||||
fsPortraitDim: 'Assombrissement de la photo',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
buttonSizeTitle: 'Boutons compacts',
|
||||
buttonSizeLabel: 'Taille des boutons',
|
||||
|
||||
@@ -720,6 +720,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-szerű',
|
||||
sidebarLyricsStyleAppleDesc: 'Az aktív sor a tetejéhez közel rögzül, sima rugós átmenetekkel.',
|
||||
seekbarStyle: 'Keresősáv stílusa',
|
||||
fullscreenPlayerStyle: 'Teljes képernyős lejátszó stílusa',
|
||||
fsLyricsStyleRail: 'Sín',
|
||||
fsLyricsStyleRailDesc: 'Klasszikus 5 soros csúszósín.',
|
||||
fsLyricsStyleApple: 'Görgetés',
|
||||
fsLyricsStyleAppleDesc: 'Teljes képernyős görgethető lista.',
|
||||
fullscreenPlayerStyleDesc: 'Válaszd ki, hogyan nézzen ki a teljes képernyős lejátszási nézet.',
|
||||
fullscreenPlayerMinimal: 'Minimál',
|
||||
fullscreenPlayerImmersive: 'Magával ragadó',
|
||||
fsShowArtistPortrait: 'Előadó fotójának megjelenítése',
|
||||
fsShowArtistPortraitDesc: 'Az előadó fotójának (vagy az albumborítónak) megjelenítése a teljes képernyős lejátszó jobb oldalán.',
|
||||
fsPortraitDim: 'Fotó sötétítése',
|
||||
seekbarStyleDesc: 'Válaszd ki a lejátszó keresősávjának kinézetét',
|
||||
buttonSizeTitle: 'Kompakt gombok',
|
||||
buttonSizeLabel: 'Gombméret',
|
||||
|
||||
@@ -714,6 +714,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music 風',
|
||||
sidebarLyricsStyleAppleDesc: 'アクティブ行を上部付近に固定し、滑らかなスプリング遷移を使います。',
|
||||
seekbarStyle: 'シークバー形式',
|
||||
fullscreenPlayerStyle: 'フルスクリーンプレーヤーのスタイル',
|
||||
fsLyricsStyleRail: 'レール',
|
||||
fsLyricsStyleRailDesc: 'クラシックな5行スライドレール。',
|
||||
fsLyricsStyleApple: 'スクロール',
|
||||
fsLyricsStyleAppleDesc: 'フルスクリーンのスクロールリスト。',
|
||||
fullscreenPlayerStyleDesc: 'フルスクリーン再生画面の見た目を選びます。',
|
||||
fullscreenPlayerMinimal: 'ミニマル',
|
||||
fullscreenPlayerImmersive: '没入型',
|
||||
fsShowArtistPortrait: 'アーティスト写真を表示',
|
||||
fsShowArtistPortraitDesc: 'フルスクリーンプレーヤーの右側にアーティスト写真(またはアルバムアート)を表示します。',
|
||||
fsPortraitDim: '写真の暗さ',
|
||||
seekbarStyleDesc: 'プレイヤーシークバーの見た目を選びます',
|
||||
buttonSizeTitle: 'コンパクトボタン',
|
||||
buttonSizeLabel: 'ボタンサイズ',
|
||||
|
||||
@@ -639,6 +639,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
fullscreenPlayerStyle: 'Stil for fullskjermspiller',
|
||||
fsLyricsStyleRail: 'Skinner',
|
||||
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
|
||||
fsLyricsStyleApple: 'Rulling',
|
||||
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
|
||||
fullscreenPlayerStyleDesc: 'Velg hvordan fullskjermsvisningen ser ut.',
|
||||
fullscreenPlayerMinimal: 'Minimal',
|
||||
fullscreenPlayerImmersive: 'Oppslukende',
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
buttonSizeTitle: 'Kompakte knapper',
|
||||
buttonSizeLabel: 'Knappestørrelse',
|
||||
|
||||
@@ -640,6 +640,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
fullscreenPlayerStyle: 'Stijl van volledig scherm-speler',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Klassieke 5-regels schuifrail.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Volledig scherm scrolllijst.',
|
||||
fullscreenPlayerStyleDesc: 'Kies hoe de volledig scherm-weergave eruitziet.',
|
||||
fullscreenPlayerMinimal: 'Minimaal',
|
||||
fullscreenPlayerImmersive: 'Meeslepend',
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
buttonSizeTitle: 'Compacte knoppen',
|
||||
buttonSizeLabel: 'Knopgrootte',
|
||||
|
||||
@@ -720,6 +720,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Jak w Apple Music',
|
||||
sidebarLyricsStyleAppleDesc: 'Aktywny wiersz pozostaje blisko góry, z płynnymi przejściami sprężynowymi.',
|
||||
seekbarStyle: 'Styl paska postępu utworu',
|
||||
fullscreenPlayerStyle: 'Styl odtwarzacza pełnoekranowego',
|
||||
fsLyricsStyleRail: 'Szyna',
|
||||
fsLyricsStyleRailDesc: 'Klasyczna 5-liniowa przesuwna szyna.',
|
||||
fsLyricsStyleApple: 'Przewijanie',
|
||||
fsLyricsStyleAppleDesc: 'Pełnoekranowa przewijana lista.',
|
||||
fullscreenPlayerStyleDesc: 'Wybierz wygląd pełnoekranowego widoku odtwarzania.',
|
||||
fullscreenPlayerMinimal: 'Minimalistyczny',
|
||||
fullscreenPlayerImmersive: 'Immersyjny',
|
||||
fsShowArtistPortrait: 'Pokaż zdjęcie artysty',
|
||||
fsShowArtistPortraitDesc: 'Wyświetlaj zdjęcie artysty (lub okładkę albumu) po prawej stronie odtwarzacza pełnoekranowego.',
|
||||
fsPortraitDim: 'Przyciemnienie zdjęcia',
|
||||
seekbarStyleDesc: 'Wybierz wygląd paska postępu utworu',
|
||||
buttonSizeTitle: 'Kompaktowe przyciski',
|
||||
buttonSizeLabel: 'Rozmiar przycisków',
|
||||
|
||||
@@ -655,6 +655,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Ca Apple Music',
|
||||
sidebarLyricsStyleAppleDesc: 'Linia activă este ancorată aproape de vârf cu animații netede.',
|
||||
seekbarStyle: 'Stilul barei de redare',
|
||||
fullscreenPlayerStyle: 'Stilul playerului pe ecran complet',
|
||||
fsLyricsStyleRail: 'Șină',
|
||||
fsLyricsStyleRailDesc: 'Șină clasica pe 5 linii.',
|
||||
fsLyricsStyleApple: 'Derulare',
|
||||
fsLyricsStyleAppleDesc: 'Listă în derulare pe ecran complet.',
|
||||
fullscreenPlayerStyleDesc: 'Alege cum arată vizualizarea de redare pe ecran complet.',
|
||||
fullscreenPlayerMinimal: 'Minimal',
|
||||
fullscreenPlayerImmersive: 'Imersiv',
|
||||
fsShowArtistPortrait: 'Arată poza artistului',
|
||||
fsShowArtistPortraitDesc: 'Arată poza artistului (sau arta albumului) în partea dreaptă a playerului pe ecran complet.',
|
||||
fsPortraitDim: 'Estomparea fotografiei',
|
||||
seekbarStyleDesc: 'Alege aspectul barei de redare a playerului',
|
||||
buttonSizeTitle: 'Butoane compacte',
|
||||
buttonSizeLabel: 'Dimensiunea butoanelor',
|
||||
|
||||
@@ -742,6 +742,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
fullscreenPlayerStyle: 'Стиль полноэкранного плеера',
|
||||
fsLyricsStyleRail: 'Рельс',
|
||||
fsLyricsStyleRailDesc: 'Классическая рамка из 5 строк.',
|
||||
fsLyricsStyleApple: 'Прокрутка',
|
||||
fsLyricsStyleAppleDesc: 'Полноэкранный список с прокруткой.',
|
||||
fullscreenPlayerStyleDesc: 'Выберите вид полноэкранного экрана воспроизведения.',
|
||||
fullscreenPlayerMinimal: 'Минимальный',
|
||||
fullscreenPlayerImmersive: 'Иммерсивный',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
buttonSizeTitle: 'Компактные кнопки',
|
||||
buttonSizeLabel: 'Размер кнопок',
|
||||
|
||||
@@ -639,6 +639,17 @@ export const settings = {
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
|
||||
seekbarStyle: '进度条样式',
|
||||
fullscreenPlayerStyle: '全屏播放器样式',
|
||||
fsLyricsStyleRail: '轨道',
|
||||
fsLyricsStyleRailDesc: '经典5行滑动轨道。',
|
||||
fsLyricsStyleApple: '滚动',
|
||||
fsLyricsStyleAppleDesc: '全屏可滚动列表。',
|
||||
fullscreenPlayerStyleDesc: '选择全屏播放视图的外观。',
|
||||
fullscreenPlayerMinimal: '简约',
|
||||
fullscreenPlayerImmersive: '沉浸式',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
buttonSizeTitle: '紧凑按钮',
|
||||
buttonSizeLabel: '按钮大小',
|
||||
|
||||
@@ -96,6 +96,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
youLyPlusEnabled: false,
|
||||
lyricsStaticOnly: false,
|
||||
sidebarLyricsStyle: 'classic',
|
||||
showFullscreenLyrics: true,
|
||||
fsLyricsStyle: 'rail',
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
fullscreenPlayerStyle: 'minimal',
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
lastDismissedThemeUpdateSig: '',
|
||||
|
||||
@@ -259,6 +259,12 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
libraryGridMaxColumns: clampLibraryGridMaxColumns(
|
||||
(state as { libraryGridMaxColumns?: unknown }).libraryGridMaxColumns,
|
||||
),
|
||||
// Immersive fullscreen-player portrait dim (0–80%). Guard against a legacy
|
||||
// or malformed persisted value so `--fs-portrait-dim` never becomes NaN.
|
||||
fsPortraitDim: (() => {
|
||||
const v = (state as { fsPortraitDim?: unknown }).fsPortraitDim;
|
||||
return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(80, Math.round(v))) : 28;
|
||||
})(),
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
|
||||
@@ -240,6 +240,11 @@ export interface AuthState {
|
||||
lyricsStaticOnly: boolean;
|
||||
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
|
||||
sidebarLyricsStyle: 'classic' | 'apple';
|
||||
showFullscreenLyrics: boolean;
|
||||
fsLyricsStyle: 'rail' | 'apple';
|
||||
showFsArtistPortrait: boolean;
|
||||
fsPortraitDim: number;
|
||||
fullscreenPlayerStyle: 'minimal' | 'immersive';
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
/** Signature of the installed-theme updates last dismissed in the sidebar
|
||||
@@ -435,6 +440,11 @@ export interface AuthState {
|
||||
setYouLyPlusEnabled: (v: boolean) => void;
|
||||
setLyricsStaticOnly: (v: boolean) => void;
|
||||
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setFullscreenPlayerStyle: (v: 'minimal' | 'immersive') => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setLastDismissedThemeUpdateSig: (v: string) => void;
|
||||
|
||||
@@ -31,6 +31,11 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
| 'setQueueDurationDisplayMode'
|
||||
| 'setQueueDisplayMode'
|
||||
| 'setSidebarLyricsStyle'
|
||||
| 'setShowFullscreenLyrics'
|
||||
| 'setFsLyricsStyle'
|
||||
| 'setShowFsArtistPortrait'
|
||||
| 'setFsPortraitDim'
|
||||
| 'setFullscreenPlayerStyle'
|
||||
| 'setShowChangelogOnUpdate'
|
||||
| 'setLastSeenChangelogVersion'
|
||||
| 'setLastDismissedThemeUpdateSig'
|
||||
@@ -56,6 +61,11 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }),
|
||||
setQueueDisplayMode: (v) => set({ queueDisplayMode: v }),
|
||||
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
|
||||
setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }),
|
||||
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
|
||||
setShowFsArtistPortrait: (v) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v) => set({ fsPortraitDim: v }),
|
||||
setFullscreenPlayerStyle: (v) => set({ fullscreenPlayerStyle: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
setLastDismissedThemeUpdateSig: (v) => set({ lastDismissedThemeUpdateSig: v }),
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
/* Immersive fullscreen player — core layout, mesh background, artist portrait,
|
||||
info cluster, controls and seekbar. Recovered for the Minimal/Immersive
|
||||
style toggle. The Apple-style lyrics overlay (.fsa-lyrics-*) is shared from
|
||||
fullscreen-player-adaptive-portrait.css. */
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Fullscreen Player — Adaptive Portrait
|
||||
──────────────────────────────────────────── */
|
||||
.fs-player {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
overflow: hidden;
|
||||
contain: layout style paint;
|
||||
animation: fsIn 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
background: #06060e;
|
||||
/* Promote to own compositor layer immediately on mount */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes fsIn {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mesh-aura-a {
|
||||
0% { transform: translate(0, 0 ); }
|
||||
33% { transform: translate(5%, -6% ); }
|
||||
66% { transform: translate(-3%, 2% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes mesh-aura-b {
|
||||
0% { transform: translate(0, 0 ); }
|
||||
50% { transform: translate(-6%, 5% ); }
|
||||
100% { transform: translate(0, 0 ); }
|
||||
}
|
||||
|
||||
@keyframes portrait-drift {
|
||||
0%, 100% { transform: translateY(0 ); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.fs-mesh-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: #06060e;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blobs are real divs — no will-change/filter to avoid software compositing layers */
|
||||
.fs-mesh-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-a {
|
||||
/* Oversized so panning never exposes the dark base underneath */
|
||||
width: 130%;
|
||||
height: 130%;
|
||||
left: -35%;
|
||||
bottom: -35%;
|
||||
/* opacity on the element avoids color-mix(…, transparent) WebKit artifacts —
|
||||
interpolating to the CSS keyword 'transparent' (rgba(0,0,0,0)) produces a
|
||||
visible halo ring at the gradient boundary on WebKitGTK. */
|
||||
background: radial-gradient(ellipse,
|
||||
var(--dynamic-fs-accent, var(--accent)) 0%,
|
||||
transparent 70%);
|
||||
opacity: 0.14;
|
||||
animation: mesh-aura-a 26s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: opacity 400ms ease-in-out;
|
||||
}
|
||||
|
||||
.fs-mesh-blob-b {
|
||||
width: 100%;
|
||||
height: 110%;
|
||||
right: -25%;
|
||||
top: -25%;
|
||||
background: radial-gradient(ellipse,
|
||||
var(--dynamic-fs-accent, var(--accent)) 0%,
|
||||
transparent 70%);
|
||||
opacity: 0.08;
|
||||
animation: mesh-aura-b 20s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
transition: opacity 400ms ease-in-out;
|
||||
}
|
||||
|
||||
/* ── Artist portrait — right half, object-fit: contain ── */
|
||||
.fs-portrait-wrap {
|
||||
position: absolute;
|
||||
right: 4vw;
|
||||
top: 4vw;
|
||||
bottom: 8vw;
|
||||
width: 46%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
/* No overflow:hidden / border-radius — object-fit:contain never overflows, and
|
||||
rounding the wrap visually clips wide images. */
|
||||
transform: translateZ(0);
|
||||
/* Thin left-edge fade to blend into the scrim — keep image fully visible */
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.6) 6%,
|
||||
#000 16%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.6) 6%,
|
||||
#000 16%
|
||||
);
|
||||
}
|
||||
|
||||
.fs-portrait {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
filter: drop-shadow(-24px 0 48px rgba(0, 0, 0, 0.85));
|
||||
transition: opacity 1000ms ease-in-out;
|
||||
will-change: transform;
|
||||
animation: portrait-drift 22s ease-in-out infinite;
|
||||
animation-delay: 350ms;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Subtle darkening overlay — plain div paint, no GPU cost.
|
||||
Alpha driven by --fs-portrait-dim (set via inline style on .fs-player). */
|
||||
.fs-portrait-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, var(--fs-portrait-dim, 0.28));
|
||||
}
|
||||
|
||||
/* ── Horizontal scrim — dark left edge for text legibility ── */
|
||||
.fs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
/* Fades out by ~52% — before the artist portrait (right ~50%–96%) so the scrim
|
||||
only darkens the left text/cluster area and does not compound with the
|
||||
portrait's own --fs-portrait-dim overlay (over-darkened it in rail mode). */
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(6, 6, 14, 0.88) 0%,
|
||||
rgba(6, 6, 14, 0.6) 24%,
|
||||
rgba(6, 6, 14, 0.2) 42%,
|
||||
transparent 52%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Close button ── */
|
||||
.fs-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 24px;
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fs-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Info cluster — bottom-left quadrant ── */
|
||||
.fs-cluster {
|
||||
position: absolute;
|
||||
bottom: 72px;
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
/* Album art — small, rounded, glowing */
|
||||
.fs-art-wrap {
|
||||
position: relative; /* stacking context for crossfade layers */
|
||||
width: clamp(120px, 10vw, 180px);
|
||||
height: clamp(120px, 10vw, 180px);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.7),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
0 0 28px color-mix(in srgb, var(--dynamic-fs-accent, var(--accent)), transparent 30%);
|
||||
transition: box-shadow 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Each layer is absolutely stacked — layers crossfade via opacity */
|
||||
.fs-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.fs-art-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Track title — massive, font-black, primary — now on top */
|
||||
.fs-track-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(28px, 4.5vw, 68px);
|
||||
font-weight: 900;
|
||||
color: var(--dynamic-fs-accent, var(--accent));
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5), 0 0 40px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
transition: color 200ms ease-in-out, text-shadow 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Artist name — secondary, below track title */
|
||||
.fs-artist-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(13px, 1.5vw, 22px);
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Metadata row */
|
||||
.fs-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: clamp(11px, 1vw, 13px);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fs-meta-dot {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Controls row — left-aligned */
|
||||
.fs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fs-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast), color 200ms ease-in-out, background-color 200ms ease-in-out;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fs-btn:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.fs-btn.active {
|
||||
color: var(--dynamic-fs-accent, var(--accent));
|
||||
}
|
||||
|
||||
.fs-btn-sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.fs-btn-play {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: var(--dynamic-fs-accent, var(--accent));
|
||||
color: var(--ctp-crust);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
|
||||
}
|
||||
|
||||
.fs-btn-play:hover {
|
||||
background: var(--dynamic-fs-accent, var(--accent));
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.1);
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
|
||||
}
|
||||
|
||||
.fs-btn-heart:hover,
|
||||
.fs-btn-heart.active {
|
||||
color: var(--dynamic-fs-accent, var(--color-star-active, var(--accent)));
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Full-width seekbar — pinned to bottom edge ── */
|
||||
.fs-seekbar-wrap {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.fs-seekbar-times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px 5px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fs-seekbar {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
cursor: pointer;
|
||||
transition: height 120ms ease;
|
||||
}
|
||||
|
||||
.fs-seekbar:hover {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.fs-seekbar-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fs-seekbar-buf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fs-seekbar-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background: var(--dynamic-fs-accent, var(--accent));
|
||||
box-shadow: 0 0 15px var(--dynamic-fs-accent, var(--accent-glow, var(--accent)));
|
||||
pointer-events: none;
|
||||
transition: background-color 200ms ease-in-out, box-shadow 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.fs-seekbar input[type="range"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* ── Idle-fade — only the close button hides; cluster + seekbar always visible ── */
|
||||
.fs-player[data-idle="true"] .fs-close {
|
||||
opacity: 0.05;
|
||||
pointer-events: none;
|
||||
transition: opacity 900ms ease;
|
||||
}
|
||||
|
||||
.fs-player[data-idle="false"] .fs-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
|
||||
/* ── Gradient fades — top and bottom chrome ──────────────────────────────── */
|
||||
|
||||
/* Top fade: covers close button area; pointer-events none so button stays clickable */
|
||||
.fsa-fade-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 110px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(6, 6, 14, 0.92) 0%,
|
||||
rgba(6, 6, 14, 0.5) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Bottom fade: sits above lyrics (same z-index, later in DOM) but under cluster */
|
||||
.fsa-fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 45vh;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
rgba(6, 6, 14, 0.55) 40%,
|
||||
rgba(6, 6, 14, 0.88) 72%,
|
||||
rgba(6, 6, 14, 0.96) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout overrides when lyrics mode is active ────────────────────────── */
|
||||
|
||||
/* Hide portrait — lyrics use the full width */
|
||||
.fs-player[data-lyrics] .fs-portrait-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrim is redundant without the portrait — hide it */
|
||||
.fs-player[data-lyrics] .fs-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Apple/scrolling mode: artist image as a dimmed full-screen backdrop behind the
|
||||
lyrics (sits above the mesh at z-index 1, below the lyrics/fades/cluster). */
|
||||
.fs-apple-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background-size: cover;
|
||||
background-position: center 30%;
|
||||
}
|
||||
.fs-apple-backdrop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(6, 6, 14, 0.62);
|
||||
}
|
||||
|
||||
/* ── Lyrics settings popover (above mic button) ─────────────────────────────── */
|
||||
|
||||
/* The panel itself — floated above the mic button.
|
||||
Rendered inside position:relative wrapper (the button wrapper).
|
||||
z-index: 9 sits above the backdrop (8). */
|
||||
.fslm-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
width: 230px;
|
||||
z-index: 9;
|
||||
background: #14141d;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 12px 44px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
animation: fslmIn 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
@keyframes fslmIn {
|
||||
from { opacity: 0; transform: scale(0.88) translateY(6px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Toggle row */
|
||||
.fslm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fslm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* Style selector */
|
||||
.fslm-style-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.fslm-disabled .fslm-style-row,
|
||||
.fslm-style-row.fslm-disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fslm-style-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.fslm-style-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fslm-style-active {
|
||||
/* accent colors set inline via accentColor prop */
|
||||
}
|
||||
|
||||
.fslm-style-name {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fslm-style-desc {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Small arrow pointing down toward the button */
|
||||
.fslm-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgba(18, 18, 28, 0.88);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ── Classic 5-line rail (Rail style) ───────────────────────────────────────── */
|
||||
|
||||
.fsr-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: 15vh;
|
||||
left: clamp(28px, 4vw, 64px);
|
||||
right: 52%;
|
||||
height: 30vh;
|
||||
z-index: 3;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: layout style;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, #000 20%, #000 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.fsr-lyrics-rail {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
will-change: transform;
|
||||
transition: transform 500ms ease-out;
|
||||
}
|
||||
|
||||
.fsr-lyric-line {
|
||||
height: 6vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 2vh;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: color 280ms ease, text-shadow 280ms ease, transform 280ms ease;
|
||||
transform-origin: left center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fsr-lyric-line:hover { color: rgba(255, 255, 255, 0.5); }
|
||||
|
||||
.fsr-lyric-line.fsrl-past { color: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
.fsr-lyric-line.fsrl-active {
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
transform: scaleX(1.015);
|
||||
}
|
||||
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.fsr-lyric-word {
|
||||
display: inline;
|
||||
white-space: pre;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.played { color: rgba(255, 255, 255, 0.75); }
|
||||
.fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 28px var(--accent-glow, var(--accent)), 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
html.no-compositing .fsr-lyrics-overlay { -webkit-mask-image: none; mask-image: none; }
|
||||
html.no-compositing .fsr-lyrics-rail { will-change: auto; }
|
||||
|
||||
/* ── no-compositing overrides ─────────────────────────────────────────────────
|
||||
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
|
||||
Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with
|
||||
software-friendly equivalents so the fullscreen player stays responsive. */
|
||||
|
||||
/* fs-player: remove will-change — without GPU compositor it only wastes RAM */
|
||||
html.no-compositing .fs-player {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Close button: replace frosted-glass blur with flat semi-opaque background */
|
||||
html.no-compositing .fs-close {
|
||||
backdrop-filter: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Portrait: remove CSS filter (drop-shadow), will-change and drift animation */
|
||||
html.no-compositing .fs-portrait {
|
||||
filter: none;
|
||||
will-change: auto;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
|
||||
pointer-events: none on wrap means the overlay never blocks clicks. */
|
||||
html.no-compositing .fs-portrait-wrap {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
html.no-compositing .fs-portrait-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#06060e 0%,
|
||||
rgba(6, 6, 14, 0.4) 6%,
|
||||
transparent 16%
|
||||
);
|
||||
}
|
||||
|
||||
/* Lyrics fade overlays: gradient-only, no backdrop-filter — fine in no-compositing */
|
||||
|
||||
/* Mesh blobs: stop animations — in software mode each frame composites surfaces
|
||||
larger than the screen (blob-a is 130 × 130 % of the viewport); on high-DPI
|
||||
displays this saturates the CPU and drops the player to ~10 FPS.
|
||||
Static gradients are preserved; only the slow pan animation is removed. */
|
||||
html.no-compositing .fs-mesh-blob {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Seekbar played bar: remove box-shadow — its width changes on every playback
|
||||
tick; in software rendering each change triggers a box-shadow repaint. */
|
||||
html.no-compositing .fs-seekbar-played {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
@import './loading-empty.css';
|
||||
@import './fullscreen-player-adaptive-portrait.css';
|
||||
@import './fullscreen-player-static.css';
|
||||
@import './fullscreen-player-immersive.css';
|
||||
@import './context-menu.css';
|
||||
@import './css-tooltips.css';
|
||||
@import './playlists-page.css';
|
||||
|
||||
Reference in New Issue
Block a user