mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 16:25:46 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bda2822db5 | |||
| 1c23305887 | |||
| 41157ccaca | |||
| dc4eef1a97 |
@@ -21,6 +21,25 @@ 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.
|
||||
|
||||
|
||||
|
||||
### Queue — Timeline display mode
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
|
||||
|
||||
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
|
||||
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
@@ -140,6 +140,12 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// counts watcher cycles so the heavy CoreAudio enumeration below runs only
|
||||
// ~every 60s (20 × 3s) instead of every cycle. Tests whether that periodic
|
||||
// enumeration is the source of the ~3s macOS playback stutter. The 3s loop,
|
||||
// watchdog and stall detector are left untouched on purpose.
|
||||
let mut diag_enum_skip: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
@@ -224,6 +230,17 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
|
||||
// run the heavy CoreAudio enumeration + device-change detection only
|
||||
// ~every 60s. The stall detector above still runs every 3s, so playback
|
||||
// recovery is unaffected. If the stutter cadence shifts to ~60s (or
|
||||
// stops), the periodic enumeration is the culprit.
|
||||
diag_enum_skip += 1;
|
||||
if diag_enum_skip < 20 {
|
||||
continue;
|
||||
}
|
||||
diag_enum_skip = 0;
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* `FullscreenPlayer` characterization (Phase F5c).
|
||||
*
|
||||
* Includes the §4.5 regression test from the v2 plan — the cover image
|
||||
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
|
||||
* third argument selects the fallback path that avoids a double
|
||||
* crossfade (fetchUrl → blobUrl). A refactor that "tidies up" the
|
||||
* useCachedUrl call sites would silently regress the FS player.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined),
|
||||
unstar: vi.fn(async () => undefined),
|
||||
getLyricsBySongId: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
|
||||
// so we can assert the third positional arg `false` is preserved.
|
||||
vi.mock('./CachedImage', async () => {
|
||||
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
|
||||
return {
|
||||
...actual,
|
||||
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
|
||||
};
|
||||
});
|
||||
|
||||
import FullscreenPlayer from './FullscreenPlayer';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — render', () => {
|
||||
it('renders the labelled Fullscreen Player dialog', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the Close Fullscreen button', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
// The component calls `useCachedUrl` twice:
|
||||
// - line 338: for the small art box (default behaviour, opt=true)
|
||||
// - line 674: for the cover (opt=false, no fetchUrl fallback)
|
||||
// The `false` arg is load-bearing — it avoids a double crossfade by
|
||||
// routing through the cache-only path. Pin it.
|
||||
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('album-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||
});
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullscreenPlayer — control wiring', () => {
|
||||
it('clicking Close Fullscreen calls the onClose prop', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const onClose = vi.fn();
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={onClose} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Close Fullscreen'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Stop calls stop()', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Stop'));
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Previous Track calls previous()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 1,
|
||||
currentTrack: makeTrack({ id: 'b' }),
|
||||
});
|
||||
usePlayerStore.setState({ currentTime: 5 });
|
||||
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Previous Track'));
|
||||
expect(prevSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Next Track calls next()', () => {
|
||||
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
|
||||
index: 0,
|
||||
currentTrack: makeTrack({ id: 'a' }),
|
||||
});
|
||||
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Next Track'));
|
||||
expect(nextSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Repeat cycles via toggleRepeat', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack() });
|
||||
const { getByLabelText } = renderWithProviders(
|
||||
<FullscreenPlayer onClose={() => {}} />,
|
||||
);
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
fireEvent.click(getByLabelText('Repeat'));
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { getCachedBlob } from '../utils/imageCache';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
|
||||
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
|
||||
import { FsArt } from './fullscreenPlayer/FsArt';
|
||||
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
|
||||
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
|
||||
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
|
||||
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
|
||||
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
|
||||
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../hooks/useQueueTracks';
|
||||
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
// Derive isStarred inside the selector so we only re-render when the boolean
|
||||
// actually flips — not when any unrelated track's star status changes.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||
|
||||
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
|
||||
const artUrl = artCover.src;
|
||||
const artKey = artCover.cacheKey;
|
||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||
const coverUrl = portraitCover.src;
|
||||
const coverKey = portraitCover.cacheKey;
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
|
||||
// Dynamic accent color extracted from the current album cover, applied as
|
||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||
// same-album tracks reuse the color without re-fetching.
|
||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
|
||||
// (thin-state): the next ref resolves from the cache (the prefetch window
|
||||
// around the current index keeps it warm).
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
useEffect(() => {
|
||||
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
|
||||
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
|
||||
nextTrack.albumId,
|
||||
nextTrack.coverArt,
|
||||
300,
|
||||
);
|
||||
getCachedBlob(url, key).catch(() => {});
|
||||
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
|
||||
|
||||
// Lyrics settings popover state
|
||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
|
||||
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const fsControlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
|
||||
const metaParts = useMemo(() => [
|
||||
currentTrack?.album,
|
||||
currentTrack?.year?.toString(),
|
||||
currentTrack?.suffix?.toUpperCase(),
|
||||
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
|
||||
].filter(Boolean), [currentTrack]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fs-player"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
data-lyrics={isAppleMode || undefined}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
<div className="fs-mesh-bg" aria-hidden="true">
|
||||
<div className="fs-mesh-blob fs-mesh-blob-a" />
|
||||
<div className="fs-mesh-blob fs-mesh-blob-b" />
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
|
||||
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
|
||||
|
||||
{/* Layer 3 — info cluster, bottom-left */}
|
||||
<div className="fs-cluster">
|
||||
|
||||
{/* Album art */}
|
||||
<div className="fs-art-wrap">
|
||||
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
|
||||
</div>
|
||||
|
||||
{/* Track title — massive statement */}
|
||||
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
|
||||
|
||||
{/* Artist — secondary, below track */}
|
||||
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
{metaParts.length > 0 && (
|
||||
<div className="fs-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fs-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="fs-controls" ref={fsControlsRef}>
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
|
||||
<Square size={13} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
|
||||
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 9 }}>
|
||||
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
|
||||
<button
|
||||
ref={lyricsMenuTriggerRef}
|
||||
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsMenuOpen(v => !v)}
|
||||
aria-label={t('player.fsLyricsToggle')}
|
||||
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
|
||||
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
|
||||
>
|
||||
<MicVocal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Layer 4 — full-width seekbar, bottom edge */}
|
||||
<FsSeekbar duration={duration} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -156,16 +156,16 @@ describe('QueuePanel — display mode', () => {
|
||||
expect(container.textContent).toContain('No upcoming tracks');
|
||||
});
|
||||
|
||||
it('header mode-toggle button flips queueDisplayMode (default queue → playlist)', () => {
|
||||
it('header mode-toggle button advances queueDisplayMode (default queue → timeline)', () => {
|
||||
seedQueue(makeTracks(3), { index: 0, currentTrack: makeTrack() });
|
||||
const { container } = renderWithProviders(<QueuePanel />);
|
||||
// The mode toggle is the first .queue-action-btn in the header (the
|
||||
// collapse chevron is the second). The default mode is 'queue', so the
|
||||
// toggle's label names its target ("Playlist").
|
||||
// collapse chevron is the second). The toggle's label names its target;
|
||||
// from the default 'queue' that is the next mode in the cycle, "Timeline".
|
||||
const toggle = container.querySelector<HTMLButtonElement>('.queue-header .queue-action-btn');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Playlist');
|
||||
expect(toggle?.getAttribute('aria-label')).toBe('Timeline');
|
||||
toggle!.click();
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('playlist');
|
||||
expect(useAuthStore.getState().queueDisplayMode).toBe('timeline');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
|
||||
// Album art box — crossfades layers so old art stays visible while new loads.
|
||||
// Uses 300px thumbnails (portrait fallback uses 500px separately).
|
||||
//
|
||||
// Why onLoad instead of new Image() preload:
|
||||
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
|
||||
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
|
||||
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
|
||||
// before we flip it to 1.
|
||||
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
|
||||
// true = show raw fetchUrl immediately as fallback while blob resolves.
|
||||
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
|
||||
// Showing the URL directly avoids the multi-second blank wait.
|
||||
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
|
||||
|
||||
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
|
||||
const counter = useRef(0);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
const id = ++counter.current;
|
||||
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const handleLoad = useCallback((id: number) => {
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
|
||||
}, []);
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{layers.map(l => (
|
||||
<img
|
||||
key={l.id}
|
||||
src={l.src}
|
||||
className="fs-art"
|
||||
style={{ opacity: l.vis ? 1 : 0 }}
|
||||
onLoad={() => handleLoad(l.id)}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
function formatClock(): string {
|
||||
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone wall-clock for the fullscreen player. Owns its own state so the
|
||||
* static player never re-renders for the time. Ticks every 30s (enough to catch
|
||||
* the minute rollover) and pauses while the window/tab is hidden.
|
||||
*/
|
||||
export const FsClock = memo(function FsClock() {
|
||||
const [time, setTime] = useState(formatClock);
|
||||
|
||||
useEffect(() => {
|
||||
let id: number | undefined;
|
||||
const tick = () => setTime(formatClock());
|
||||
const sync = () => {
|
||||
if (document.hidden) {
|
||||
if (id !== undefined) { clearInterval(id); id = undefined; }
|
||||
} else {
|
||||
tick();
|
||||
if (id === undefined) id = window.setInterval(tick, 30_000);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
return () => {
|
||||
if (id !== undefined) clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <span className="fsp-clock">{time}</span>;
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
accentColor: string | null;
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// Lyrics settings popover — shown above the mic button.
|
||||
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
|
||||
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
|
||||
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside the panel or on Escape.
|
||||
// Ignore clicks on the trigger button so re-clicking it toggles normally
|
||||
// instead of outside-handler closing + click re-opening.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onMouse = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (triggerRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onMouse);
|
||||
};
|
||||
}, [open, onClose, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const accent = accentColor ?? 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="fslm-panel" ref={panelRef}>
|
||||
<div className="fslm-row">
|
||||
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
|
||||
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLyrics}
|
||||
onChange={e => setLyrics(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
|
||||
{(['rail', 'apple'] as const).map(style => (
|
||||
<button
|
||||
key={style}
|
||||
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
|
||||
onClick={() => setStyle(style)}
|
||||
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
|
||||
>
|
||||
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
|
||||
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fslm-arrow" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLyrics, type WordLyricsLine } from '../../hooks/useLyrics';
|
||||
import { useWordLyricsSync } from '../../hooks/useWordLyricsSync';
|
||||
import type { LrcLine } from '../../api/lrclib';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
// Classic 5-line rail lyrics (original "Rail" style).
|
||||
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
|
||||
export const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
|
||||
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
|
||||
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
|
||||
|
||||
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
|
||||
const lineSrc: LrcLine[] | null = useWords
|
||||
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
|
||||
: (syncedLines as LrcLine[] | null);
|
||||
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
|
||||
|
||||
const linesRef = useRef<LrcLine[]>([]);
|
||||
linesRef.current = hasSynced ? lineSrc! : [];
|
||||
|
||||
const activeIdx = usePlayerStore(s => {
|
||||
const ls = linesRef.current;
|
||||
if (ls.length === 0) return -1;
|
||||
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
|
||||
});
|
||||
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
const slotH = useRef(window.innerHeight * 0.06);
|
||||
useEffect(() => {
|
||||
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
|
||||
if (!target || duration <= 0) return;
|
||||
seek(parseFloat(target.dataset.time!) / duration);
|
||||
}, [duration, seek]);
|
||||
|
||||
const { setWordRef } = useWordLyricsSync({
|
||||
enabled: useWords,
|
||||
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
|
||||
currentTrack,
|
||||
classPrefix: 'fsr',
|
||||
});
|
||||
|
||||
if (!currentTrack || loading || !hasSynced) return null;
|
||||
|
||||
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
|
||||
|
||||
return (
|
||||
<div className="fsr-lyrics-overlay" aria-hidden="true">
|
||||
<div
|
||||
className="fsr-lyrics-rail"
|
||||
style={{ transform: `translateY(${railY}px)` }}
|
||||
onClick={handleLineClick}
|
||||
>
|
||||
{useWords
|
||||
? (wordLines as WordLyricsLine[]).map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.words.length > 0 ? line.words.map((w, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="fsr-lyric-word"
|
||||
ref={setWordRef(i, j)}
|
||||
>{w.text}</span>
|
||||
)) : (line.text || ' ')}
|
||||
</div>
|
||||
))
|
||||
: lineSrc!.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
|
||||
data-time={line.time}
|
||||
>
|
||||
{line.text || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const FsPlayBtn = memo(function FsPlayBtn({
|
||||
: <Sunrise size={12} strokeWidth={2.5} />}
|
||||
<span className="player-btn-schedule-time player-btn-schedule-time--fs">{scheduleRemaining.remaining}</span>
|
||||
</span>
|
||||
) : isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
) : isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
</button>
|
||||
</span>
|
||||
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={controlsAnchorRef} />
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Artist portrait — right half, crossfades on track change.
|
||||
export const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
|
||||
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
||||
url ? [{ url, id: 0, visible: true }] : []
|
||||
);
|
||||
const counterRef = useRef(1);
|
||||
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const id = counterRef.current++;
|
||||
const img = new Image();
|
||||
img.onload = img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setLayers(prev => [...prev, { url, id, visible: false }]);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
|
||||
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
||||
cleanupTimer.current = setTimeout(() => {
|
||||
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
img.src = url;
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (layers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fs-portrait-wrap" aria-hidden="true">
|
||||
{layers.map(layer => (
|
||||
<img
|
||||
key={layer.id}
|
||||
src={layer.url}
|
||||
className="fs-portrait"
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { memo, useMemo, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '../../utils/library/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '../../utils/library/queueTrackResolver';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semi-transparent "Up next" overlay for the fullscreen player — lists the
|
||||
* upcoming queue (no blur, in keeping with the static player). Clicking a row
|
||||
* jumps to that queue item (same-queue jump as the queue panel).
|
||||
*/
|
||||
export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queueItems = usePlayerStore(s => s.queueItems);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
// Re-resolve as the resolver cache fills.
|
||||
const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { track: Track; absIdx: number }[] = [];
|
||||
for (let i = queueIndex + 1; i < queueItems.length; i++) {
|
||||
const ref = queueItems[i];
|
||||
if (ref) out.push({ track: resolveQueueTrack(ref), absIdx: i });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queueItems, queueIndex, version]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsq-backdrop"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fsUpNext')}
|
||||
>
|
||||
<div className="fsq-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="fsq-header">
|
||||
<span className="fsq-title">{t('player.fsUpNext')}</span>
|
||||
<button className="fsq-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="fsq-list">
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="fsq-empty">{t('player.fsQueueEmpty')}</div>
|
||||
) : (
|
||||
upcoming.map(({ track, absIdx }) => (
|
||||
<button
|
||||
key={`${track.id}:${absIdx}`}
|
||||
className="fsq-item"
|
||||
onClick={() => {
|
||||
playTrack(track, undefined, undefined, undefined, absIdx);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="fsq-item-pos">{absIdx + 1}</span>
|
||||
<span className="fsq-item-info">
|
||||
<span className="fsq-item-title">{track.title}</span>
|
||||
<span className="fsq-item-artist">{track.artist}</span>
|
||||
</span>
|
||||
<span className="fsq-item-dur">{formatTrackTime(track.duration ?? 0)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
// Full-width seekbar — imperative DOM updates, zero React re-renders on tick.
|
||||
export const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const timeRef = useRef<HTMLSpanElement>(null);
|
||||
const playedRef = useRef<HTMLDivElement>(null);
|
||||
const bufRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
const previewSeek = useCallback((progress: number) => {
|
||||
const s = usePlayerStore.getState();
|
||||
const p = Math.max(0, Math.min(1, progress));
|
||||
pendingSeekRef.current = p;
|
||||
if (timeRef.current) {
|
||||
const previewTime = duration > 0 ? p * duration : s.currentTime;
|
||||
timeRef.current.textContent = formatTrackTime(previewTime);
|
||||
}
|
||||
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(p);
|
||||
}, [duration]);
|
||||
|
||||
const commitSeek = useCallback(() => {
|
||||
const pending = pendingSeekRef.current;
|
||||
if (pending === null) return;
|
||||
pendingSeekRef.current = null;
|
||||
seek(pending);
|
||||
}, [seek]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = getPlaybackProgressSnapshot();
|
||||
const pct = s.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(s.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${pct}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(pct, s.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(s.progress);
|
||||
|
||||
return subscribePlaybackProgress(state => {
|
||||
if (isDraggingRef.current) return;
|
||||
const p = state.progress * 100;
|
||||
if (timeRef.current) timeRef.current.textContent = formatTrackTime(state.currentTime);
|
||||
if (playedRef.current) playedRef.current.style.width = `${p}%`;
|
||||
if (bufRef.current) bufRef.current.style.width = `${Math.max(p, state.buffered * 100)}%`;
|
||||
if (inputRef.current) inputRef.current.value = String(state.progress);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
previewSeek(parseFloat(e.target.value));
|
||||
},
|
||||
[previewSeek]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fs-seekbar-wrap">
|
||||
<div className="fs-seekbar-times">
|
||||
<span ref={timeRef} />
|
||||
<span>{formatTrackTime(duration)}</span>
|
||||
</div>
|
||||
<div className="fs-seekbar">
|
||||
<div className="fs-seekbar-bg" />
|
||||
<div className="fs-seekbar-buf" ref={bufRef} />
|
||||
<div className="fs-seekbar-played" ref={playedRef} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
defaultValue={0}
|
||||
onChange={handleSeek}
|
||||
onMouseDown={() => { isDraggingRef.current = true; }}
|
||||
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onTouchStart={() => { isDraggingRef.current = true; }}
|
||||
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onPointerDown={() => { isDraggingRef.current = true; }}
|
||||
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
onKeyUp={commitSeek}
|
||||
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
|
||||
aria-label="seek"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
/**
|
||||
* Centered "current / total" readout for the control bar. Updates the current
|
||||
* time imperatively from the playback-progress store — no React re-render per
|
||||
* tick (same pattern as FsSeekbar).
|
||||
*/
|
||||
export const FsTimeReadout = memo(function FsTimeReadout({ duration }: { duration: number }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (state: { currentTime: number }) => {
|
||||
if (ref.current) ref.current.textContent = formatTrackTime(state.currentTime);
|
||||
};
|
||||
apply(getPlaybackProgressSnapshot());
|
||||
return subscribePlaybackProgress(apply);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="fsp-time">
|
||||
<span ref={ref} /> / {formatTrackTime(duration)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
SkipBack, SkipForward, Square, Repeat, Repeat1, Heart,
|
||||
Shuffle, ListMusic, ChevronDown, Star, MicVocal,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '../../store/pendingStarSync';
|
||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
import { useFsArtistPortrait } from '../../hooks/useFsArtistPortrait';
|
||||
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
|
||||
import WaveformSeek from '../WaveformSeek';
|
||||
import { FsQueueModal } from './FsQueueModal';
|
||||
import { FsLyricsApple } from './FsLyricsApple';
|
||||
import { FsPlayBtn } from './FsPlayBtn';
|
||||
import { FsClock } from './FsClock';
|
||||
import { FsTimeReadout } from './FsTimeReadout';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const shuffleUpcomingQueue = usePlayerStore(s => s.shuffleUpcomingQueue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const queueLen = usePlayerStore(s => s.queueItems.length);
|
||||
|
||||
// Derive the boolean inside the selector so the cluster only re-renders when
|
||||
// the star actually flips, not on any unrelated track's star change.
|
||||
const isStarred = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return false;
|
||||
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
|
||||
});
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Album-keyed cover ref so the cover stays stable across track changes within
|
||||
// the same album. A per-track ref re-keys on `track.coverArt` (Navidrome hands
|
||||
// out `mf-<trackId>` per track), which the distinct-disc heuristic mistakes for
|
||||
// per-disc art and reloads the cover on every song change. Keying on albumId
|
||||
// sidesteps that — same lesson as the artist portrait keying on artistId.
|
||||
// `usePlaybackCoverArt` still re-scopes it to the playback server.
|
||||
const playbackCoverRef =
|
||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
||||
// One high-res cover (cucadmuh's fullRes 2000px path) feeds both the background
|
||||
// fallback and the foreground thumbnail: crisp instead of the old low-res tier,
|
||||
// and a single fetch/decode shared by both.
|
||||
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
||||
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
|
||||
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
||||
const resolvedCoverUrl = coverUrl;
|
||||
const thumbUrl = coverUrl;
|
||||
// Artist photo is the background; fall back to the album cover.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
|
||||
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [lyricsOpen, setLyricsOpen] = useState(false);
|
||||
|
||||
// Prefix the title with the queue position so it matches "Track x / N".
|
||||
const titlePrefix = queueLen > 0
|
||||
? `${String(queueIndex + 1).padStart(2, '0')}. `
|
||||
: '';
|
||||
const metaParts = useMemo(
|
||||
() => [currentTrack?.year?.toString(), currentTrack?.genre].filter(Boolean) as string[],
|
||||
[currentTrack?.year, currentTrack?.genre],
|
||||
);
|
||||
// Override-aware rating (a just-set rating lives in the override before it syncs
|
||||
// back onto the track object).
|
||||
const rating = usePlayerStore(s => {
|
||||
const track = s.currentTrack;
|
||||
if (!track) return 0;
|
||||
return track.id in s.userRatingOverrides ? s.userRatingOverrides[track.id] : (track.userRating ?? 0);
|
||||
});
|
||||
// Hover preview for the clickable rating stars (0 = no preview).
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const applyRating = useCallback((stars: number) => {
|
||||
if (!currentTrack) return;
|
||||
// Click the current rating again to clear it (matches StarRating's toggle-off).
|
||||
queueSongRating(currentTrack.id, rating === stars ? 0 : stars);
|
||||
}, [currentTrack, rating]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fsp"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Static sharp background — no blur, no animation */}
|
||||
{bgUrl
|
||||
? <img className="fsp-bg" src={bgUrl} alt="" aria-hidden="true" draggable={false} />
|
||||
: <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />}
|
||||
<div className="fsp-scrim" aria-hidden="true" />
|
||||
<div className="fsp-vignette" aria-hidden="true" />
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="fsp-top">
|
||||
<div className="fsp-nowplaying">
|
||||
<span className="fsp-nowplaying-label">{t('player.fsNowPlaying')}</span>
|
||||
{queueLen > 0 && (
|
||||
<span className="fsp-nowplaying-pos">{t('player.fsTrackPosition', { current: queueIndex + 1, total: queueLen })}</span>
|
||||
)}
|
||||
</div>
|
||||
<FsClock />
|
||||
</div>
|
||||
|
||||
<button className="fsp-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="fsp-foot">
|
||||
<div className="fsp-info-row">
|
||||
{/* Big cover — bottom-aligned with the text, top pokes above the bar */}
|
||||
<div className="fsp-cover">
|
||||
{thumbUrl
|
||||
? <img className="fsp-cover-img" src={thumbUrl} alt="" draggable={false} />
|
||||
: <div className="fsp-cover-img fsp-cover-img--empty" />}
|
||||
</div>
|
||||
<div className="fsp-info-text">
|
||||
<p className="fsp-title">{titlePrefix}{currentTrack?.title ?? '—'}</p>
|
||||
<p className="fsp-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
{currentTrack && (
|
||||
<div className="fsp-meta">
|
||||
{metaParts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="fsp-meta-dot">·</span>}
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span
|
||||
className="fsp-stars"
|
||||
role="radiogroup"
|
||||
aria-label={t('albumDetail.ratingLabel')}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const n = i + 1;
|
||||
const filled = (hoverRating || rating) >= n;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="fsp-star-btn"
|
||||
role="radio"
|
||||
aria-checked={rating === n}
|
||||
aria-label={`${n}`}
|
||||
onMouseEnter={() => setHoverRating(n)}
|
||||
onClick={() => applyRating(n)}
|
||||
>
|
||||
<Star size={16} fill={filled ? 'currentColor' : 'none'} strokeWidth={1.5} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nextTrack && (
|
||||
<p className="fsp-next">{t('player.fsNext')}: {nextTrack.artist} – {nextTrack.title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fsp-controls" ref={controlsRef}>
|
||||
<div className="fsp-transport">
|
||||
<button className="fsp-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<FsPlayBtn controlsAnchorRef={controlsRef} />
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fsp-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FsTimeReadout duration={duration} />
|
||||
|
||||
<div className="fsp-actions">
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={() => setQueueOpen(true)} aria-label={t('queue.title')} data-tooltip={t('queue.title')}>
|
||||
<ListMusic size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${lyricsOpen ? ' active' : ''}`}
|
||||
onClick={() => setLyricsOpen(v => !v)}
|
||||
aria-label={t('player.lyrics')}
|
||||
data-tooltip={t('player.lyrics')}
|
||||
>
|
||||
<MicVocal size={20} />
|
||||
</button>
|
||||
{currentTrack && (
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={20} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`fsp-btn fsp-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
<button className="fsp-btn fsp-btn-sm" onClick={shuffleUpcomingQueue} aria-label={t('player.shuffle')} data-tooltip={t('player.shuffle')}>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* True waveform seekbar (cucadmuh's idea) instead of the thin bar. */}
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
|
||||
{queueOpen && <FsQueueModal onClose={() => setQueueOpen(false)} />}
|
||||
|
||||
{/* Scrolling synced lyrics (reuses FsLyricsApple) in a semi-transparent
|
||||
overlay over the upper area. */}
|
||||
{lyricsOpen && (
|
||||
<div className="fsp-lyrics-overlay">
|
||||
<FsLyricsApple currentTrack={currentTrack} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDeferredValue, useMemo, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { AlignCenterVertical, ChevronDown, ListMusic, ListOrdered } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
@@ -81,6 +81,16 @@ export function QueueHeader({
|
||||
|
||||
const isEta = durationMode === 'eta';
|
||||
|
||||
// Cycle the panel through the three render modes; icon + title show the active
|
||||
// one, tooltip names the one a click switches to.
|
||||
const DISPLAY_MODE_CYCLE: QueueDisplayMode[] = ['queue', 'timeline', 'playlist'];
|
||||
const nextDisplayMode =
|
||||
DISPLAY_MODE_CYCLE[(DISPLAY_MODE_CYCLE.indexOf(queueDisplayMode) + 1) % DISPLAY_MODE_CYCLE.length];
|
||||
const displayModeLabel = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? t('queue.modePlaylist') : m === 'timeline' ? t('queue.modeTimeline') : t('queue.title');
|
||||
const displayModeIcon = (m: QueueDisplayMode) =>
|
||||
m === 'playlist' ? <ListMusic size={15} /> : m === 'timeline' ? <AlignCenterVertical size={15} /> : <ListOrdered size={15} />;
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
@@ -91,17 +101,17 @@ export function QueueHeader({
|
||||
<button
|
||||
type="button"
|
||||
className="queue-action-btn"
|
||||
onClick={() => setQueueDisplayMode(queueDisplayMode === 'playlist' ? 'queue' : 'playlist')}
|
||||
data-tooltip={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
aria-label={queueDisplayMode === 'playlist' ? t('queue.title') : t('queue.modePlaylist')}
|
||||
onClick={() => setQueueDisplayMode(nextDisplayMode)}
|
||||
data-tooltip={displayModeLabel(nextDisplayMode)}
|
||||
aria-label={displayModeLabel(nextDisplayMode)}
|
||||
style={{ width: 24, height: 24, alignSelf: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{queueDisplayMode === 'playlist' ? <ListMusic size={15} /> : <ListOrdered size={15} />}
|
||||
{displayModeIcon(queueDisplayMode)}
|
||||
</button>
|
||||
{/* Title doubles as the mode indicator so the panel reads "Playlist"
|
||||
in playlist mode rather than the misleading "Queue". */}
|
||||
{/* Title doubles as the mode indicator so the panel names the active
|
||||
mode rather than always reading "Queue". */}
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>
|
||||
{queueDisplayMode === 'playlist' ? t('queue.modePlaylist') : t('queue.title')}
|
||||
{displayModeLabel(queueDisplayMode)}
|
||||
</h2>
|
||||
{queue.length > 0 && (
|
||||
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
|
||||
|
||||
@@ -108,6 +108,16 @@ export function QueueList({
|
||||
return pinToTop(0, displayBaseIndex);
|
||||
}
|
||||
|
||||
if (queueDisplayMode === 'timeline') {
|
||||
// Anchor the current track in the middle — history above, up-next below.
|
||||
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
|
||||
const id = requestAnimationFrame(() => {
|
||||
const el = queueListRef.current?.querySelector<HTMLElement>(`[data-queue-idx="${queueIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
// Playlist: lazy. Let the highlight wander while the now-playing row stays
|
||||
// visible; only re-pin it to the top once it has scrolled out of view.
|
||||
const viewport = queueListRef.current;
|
||||
@@ -148,6 +158,8 @@ export function QueueList({
|
||||
const base = queue[idx];
|
||||
const track = resolveQueueTrack(base);
|
||||
const isPlaying = absIdx === queueIndex;
|
||||
const isTimeline = queueDisplayMode === 'timeline';
|
||||
const isPast = isTimeline && absIdx < queueIndex;
|
||||
const isFirstAutoAdded = base.autoAdded && (idx === 0 || !queue[idx - 1].autoAdded);
|
||||
const isFirstRadioAdded = base.radioAdded && (idx === 0 || !queue[idx - 1].radioAdded);
|
||||
|
||||
@@ -169,6 +181,16 @@ export function QueueList({
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{isTimeline && idx === 0 && queueIndex > 0 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.history')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isTimeline && absIdx === queueIndex + 1 && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.upNext')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isFirstRadioAdded && (
|
||||
<div className="queue-divider" style={{ margin: '2px 0' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: 'var(--text-muted)' }}>{t('queue.radioAdded')}</span>
|
||||
@@ -213,7 +235,7 @@ export function QueueList({
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Clock, LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { Clock, LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
@@ -367,41 +367,6 @@ export function AppearanceTab() {
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.fsPlayerSection')}
|
||||
icon={<Maximize2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.fsShowArtistPortrait')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.fsShowArtistPortraitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.fsShowArtistPortrait')}>
|
||||
<input type="checkbox" checked={auth.showFsArtistPortrait} onChange={e => auth.setShowFsArtistPortrait(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.fsPortraitDim')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 36, textAlign: 'right' }}>{auth.fsPortraitDim}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={auth.fsPortraitDim}
|
||||
onChange={e => auth.setFsPortraitDim(parseInt(e.target.value, 10))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={t('settings.seekbarStyle')}
|
||||
icon={<Sliders size={16} />}
|
||||
|
||||
@@ -84,8 +84,8 @@ export function PersonalisationTab() {
|
||||
icon={<ListOrdered size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Two mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the other off; the active one cannot be
|
||||
{/* Three mutually exclusive modes — exactly one is always active, so
|
||||
turning one on turns the others off; the active one cannot be
|
||||
switched off directly (ignore the uncheck). */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
@@ -116,6 +116,21 @@ export function PersonalisationTab() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={queueDisplayMode === 'timeline'}
|
||||
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
|
||||
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
|
||||
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
|
||||
{ tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
|
||||
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
|
||||
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
|
||||
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
export function usePlaybackCoverArt(
|
||||
coverRef: CoverArtRef | undefined,
|
||||
displayCssPx: number,
|
||||
opts?: { fullRes?: boolean },
|
||||
): CoverArtHandle {
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const queueLength = usePlayerStore(s => s.queueItems.length);
|
||||
@@ -29,5 +30,6 @@ export function usePlaybackCoverArt(
|
||||
);
|
||||
return useCoverArt(refWithScope, displayCssPx, {
|
||||
surface: 'sparse',
|
||||
fullRes: opts?.fullRes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { extractCoverColors } from '../utils/ui/dynamicColors';
|
||||
|
||||
// Module-level cache: artKey → accent color string.
|
||||
// Survives track changes so same-album songs reuse the extracted color instantly.
|
||||
const coverAccentCache = new Map<string, string>();
|
||||
|
||||
/** Extract a dominant accent color from the current cover art and cache it by
|
||||
* artKey. Cache hits return instantly; cache misses fetch the cover blob,
|
||||
* run extractCoverColors, then cache + apply the result. Keeps the previous
|
||||
* color visible until extraction completes so the UI doesn't flash to default. */
|
||||
export function useFsDynamicAccent(artUrl: string, artKey: string): string | null {
|
||||
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
|
||||
const cached = coverAccentCache.get(artKey);
|
||||
if (cached) { setDynamicAccent(cached); return; }
|
||||
let cancelled = false;
|
||||
let blobUrl = '';
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(artUrl);
|
||||
if (cancelled) return;
|
||||
const blob = await resp.blob();
|
||||
if (cancelled) return;
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
const colors = await extractCoverColors(blobUrl);
|
||||
if (cancelled) return;
|
||||
if (colors.accent) {
|
||||
coverAccentCache.set(artKey, colors.accent);
|
||||
setDynamicAccent(colors.accent);
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artKey]);
|
||||
|
||||
return dynamicAccent;
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Geschwindigkeit',
|
||||
miniPlayer: 'Mini-Player',
|
||||
lyrics: 'Lyrics',
|
||||
fsNowPlaying: 'Wird gespielt…',
|
||||
fsTrackPosition: 'Titel {{current}} / {{total}}',
|
||||
fsNext: 'Als Nächstes',
|
||||
fsUpNext: 'Als Nächstes',
|
||||
fsQueueEmpty: 'Nichts in der Warteschlange.',
|
||||
shuffle: 'Zufallswiedergabe',
|
||||
fsLyricsToggle: 'Lyrics im Vollbild',
|
||||
lyricsLoading: 'Lyrics werden geladen…',
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Warteschlange',
|
||||
modePlaylist: 'Playlist',
|
||||
modeTimeline: 'Zeitachse',
|
||||
upNext: 'Als Nächstes',
|
||||
history: 'Verlauf',
|
||||
noUpcoming: 'Keine weiteren Titel.',
|
||||
savePlaylist: 'Playlist speichern',
|
||||
updatePlaylist: 'Playlist aktualisieren',
|
||||
|
||||
@@ -330,6 +330,7 @@ export const settings = {
|
||||
queueModeTitle: 'Warteschlangen-Ansicht',
|
||||
queueModeQueueSub: 'Zeigt nur kommende Titel. Der laufende Titel bleibt im Kopf und verschwindet aus der Liste, sobald er gespielt wurde.',
|
||||
queueModePlaylistSub: 'Behält die ganze Warteschlange in der Liste, der laufende Titel oben hervorgehoben; gespielte Titel bleiben stehen.',
|
||||
queueModeTimelineSub: 'Aktuellen Titel zentrieren — gespielter Verlauf darüber, kommende Titel (in Shuffle-Reihenfolge) darunter.',
|
||||
queueToolbarTitle: 'Warteschlangen-Toolbar',
|
||||
queueToolbarReset: 'Zurücksetzen',
|
||||
queueToolbarSeparator: 'Trennlinie',
|
||||
@@ -501,15 +502,6 @@ export const settings = {
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
fsPlayerSection: 'Vollbild-Player',
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
fsLyricsStyle: 'Liedtext-Stil',
|
||||
fsLyricsStyleRail: 'Schiene',
|
||||
fsLyricsStyleRailDesc: 'Klassische 5-Zeilen-Schiene.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Vollbild-Scrollliste.',
|
||||
sidebarLyricsStyle: 'Text-Scroll-Stil',
|
||||
sidebarLyricsStyleClassic: 'Klassisch',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktive Zeile wird zentriert.',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Playback speed',
|
||||
miniPlayer: 'Mini Player',
|
||||
lyrics: 'Lyrics',
|
||||
fsNowPlaying: 'Now playing…',
|
||||
fsTrackPosition: 'Track {{current}} / {{total}}',
|
||||
fsNext: 'Next',
|
||||
fsUpNext: 'Up next',
|
||||
fsQueueEmpty: 'Nothing queued.',
|
||||
shuffle: 'Shuffle',
|
||||
fsLyricsToggle: 'Lyrics in fullscreen',
|
||||
lyricsLoading: 'Loading lyrics…',
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Queue',
|
||||
modePlaylist: 'Playlist',
|
||||
modeTimeline: 'Timeline',
|
||||
upNext: 'Up next',
|
||||
history: 'History',
|
||||
noUpcoming: 'No upcoming tracks.',
|
||||
savePlaylist: 'Save Playlist',
|
||||
updatePlaylist: 'Update Playlist',
|
||||
|
||||
@@ -394,6 +394,7 @@ export const settings = {
|
||||
queueModeTitle: 'Queue Display Mode',
|
||||
queueModeQueueSub: 'Show only upcoming tracks. The current track stays in the header and leaves the list once it has played.',
|
||||
queueModePlaylistSub: 'Keep the whole queue in the list with the current track highlighted at the top; played tracks stay.',
|
||||
queueModeTimelineSub: 'Center the current track, with played history above and upcoming tracks (in shuffle order) below.',
|
||||
queueToolbarTitle: 'Queue Toolbar',
|
||||
queueToolbarReset: 'Reset to default',
|
||||
queueToolbarSeparator: 'Separator',
|
||||
@@ -588,20 +589,11 @@ export const settings = {
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Fullscreen Player',
|
||||
fsLyricsStyle: 'Lyrics style',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Classic 5-line sliding rail.',
|
||||
fsLyricsStyleApple: 'Scrolling',
|
||||
fsLyricsStyleAppleDesc: 'Full-screen scrollable list.',
|
||||
sidebarLyricsStyle: 'Lyrics scroll style',
|
||||
sidebarLyricsStyleClassic: 'Classic',
|
||||
sidebarLyricsStyleClassicDesc: 'Scroll active line to center.',
|
||||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||||
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
seekbarStyle: 'Seekbar Style',
|
||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||
seekbarTruewave: 'Truewave',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Velocidad',
|
||||
miniPlayer: 'Mini reproductor',
|
||||
lyrics: 'Letras',
|
||||
fsNowPlaying: 'Reproduciendo…',
|
||||
fsTrackPosition: 'Pista {{current}} / {{total}}',
|
||||
fsNext: 'Siguiente',
|
||||
fsUpNext: 'A continuación',
|
||||
fsQueueEmpty: 'Nada en cola.',
|
||||
shuffle: 'Aleatorio',
|
||||
fsLyricsToggle: 'Letras en pantalla completa',
|
||||
lyricsLoading: 'Cargando letras…',
|
||||
lyricsNotFound: 'No se encontraron letras para esta pista',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Cola',
|
||||
modePlaylist: 'Lista',
|
||||
modeTimeline: 'Cronología',
|
||||
upNext: 'A continuación',
|
||||
history: 'Historial',
|
||||
noUpcoming: 'No hay próximas pistas.',
|
||||
savePlaylist: 'Guardar Lista',
|
||||
updatePlaylist: 'Actualizar Lista',
|
||||
|
||||
@@ -329,6 +329,7 @@ export const settings = {
|
||||
queueModeTitle: 'Modo de visualización de la cola',
|
||||
queueModeQueueSub: 'Muestra solo las próximas pistas. La pista actual permanece en el encabezado y sale de la lista al reproducirse.',
|
||||
queueModePlaylistSub: 'Mantiene toda la cola en la lista, con la pista actual resaltada arriba; las reproducidas permanecen.',
|
||||
queueModeTimelineSub: 'Centrar la pista actual, con el historial reproducido arriba y las próximas pistas (en orden aleatorio) debajo.',
|
||||
queueToolbarTitle: 'Barra de herramientas de cola',
|
||||
queueToolbarReset: 'Restablecer a predeterminado',
|
||||
queueToolbarSeparator: 'Separador',
|
||||
@@ -500,15 +501,6 @@ export const settings = {
|
||||
infiniteQueue: 'Cola Infinita',
|
||||
infiniteQueueDesc: 'Agregar automáticamente pistas aleatorias cuando la cola termina',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Reproductor Pantalla Completa',
|
||||
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',
|
||||
fsLyricsStyle: 'Estilo de letra',
|
||||
fsLyricsStyleRail: 'Carril',
|
||||
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
|
||||
fsLyricsStyleApple: 'Desplazamiento',
|
||||
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
|
||||
sidebarLyricsStyle: 'Estilo de desplazamiento de letra',
|
||||
sidebarLyricsStyleClassic: 'Clásico',
|
||||
sidebarLyricsStyleClassicDesc: 'La línea activa se centra.',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Vitesse',
|
||||
miniPlayer: 'Mini-lecteur',
|
||||
lyrics: 'Paroles',
|
||||
fsNowPlaying: "Lecture en cours…",
|
||||
fsTrackPosition: "Piste {{current}} / {{total}}",
|
||||
fsNext: "Suivant",
|
||||
fsUpNext: "À suivre",
|
||||
fsQueueEmpty: "File d'attente vide.",
|
||||
shuffle: "Aléatoire",
|
||||
fsLyricsToggle: 'Paroles en plein écran',
|
||||
lyricsLoading: 'Chargement des paroles…',
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'File d\'attente',
|
||||
modePlaylist: 'Liste de lecture',
|
||||
modeTimeline: 'Chronologie',
|
||||
upNext: 'À suivre',
|
||||
history: 'Historique',
|
||||
noUpcoming: 'Aucune piste à venir.',
|
||||
savePlaylist: 'Enregistrer la liste',
|
||||
updatePlaylist: 'Mettre à jour la liste',
|
||||
|
||||
@@ -327,6 +327,7 @@ export const settings = {
|
||||
queueModeTitle: 'Mode d\'affichage de la file',
|
||||
queueModeQueueSub: 'N\'affiche que les pistes à venir. La piste en cours reste dans l\'en-tête et quitte la liste une fois jouée.',
|
||||
queueModePlaylistSub: 'Conserve toute la file dans la liste, la piste en cours surlignée en haut ; les pistes jouées restent.',
|
||||
queueModeTimelineSub: 'Centrer le morceau en cours, avec l\'historique au-dessus et les morceaux à venir (dans l\'ordre aléatoire) en dessous.',
|
||||
queueToolbarTitle: 'Barre d\'outils de file d\'attente',
|
||||
queueToolbarReset: 'Réinitialiser',
|
||||
queueToolbarSeparator: 'Séparateur',
|
||||
@@ -498,15 +499,6 @@ export const settings = {
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
fsPlayerSection: 'Lecteur plein écran',
|
||||
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",
|
||||
fsLyricsStyle: "Style des paroles",
|
||||
fsLyricsStyleRail: "Rail",
|
||||
fsLyricsStyleRailDesc: "Rail glissant classique de 5 lignes.",
|
||||
fsLyricsStyleApple: "Défilement",
|
||||
fsLyricsStyleAppleDesc: "Liste déroulante plein écran.",
|
||||
sidebarLyricsStyle: "Style de défilement des paroles",
|
||||
sidebarLyricsStyleClassic: "Classique",
|
||||
sidebarLyricsStyleClassicDesc: "La ligne active est centrée.",
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Hastighet',
|
||||
miniPlayer: 'Minispiller',
|
||||
lyrics: 'Sangtekst',
|
||||
fsNowPlaying: 'Spilles nå…',
|
||||
fsTrackPosition: 'Spor {{current}} / {{total}}',
|
||||
fsNext: 'Neste',
|
||||
fsUpNext: 'Neste',
|
||||
fsQueueEmpty: 'Ingenting i køen.',
|
||||
shuffle: 'Tilfeldig',
|
||||
fsLyricsToggle: 'Sangtekst i fullskjerm',
|
||||
lyricsLoading: 'Laster sangtekst…',
|
||||
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Kø',
|
||||
modePlaylist: 'Spilleliste',
|
||||
modeTimeline: 'Tidslinje',
|
||||
upNext: 'Neste',
|
||||
history: 'Historikk',
|
||||
noUpcoming: 'Ingen kommende spor.',
|
||||
savePlaylist: 'Lagre spilleliste',
|
||||
updatePlaylist: 'Oppdater spilleliste',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const settings = {
|
||||
queueModeTitle: 'Visningsmodus for kø',
|
||||
queueModeQueueSub: 'Viser bare kommende spor. Det gjeldende sporet blir i toppen og forsvinner fra listen når det er spilt.',
|
||||
queueModePlaylistSub: 'Beholder hele køen i listen med gjeldende spor uthevet øverst; avspilte spor blir værende.',
|
||||
queueModeTimelineSub: 'Sentrer gjeldende spor, med avspilt historikk over og kommende spor (i shuffle-rekkefølge) under.',
|
||||
queueToolbarTitle: 'Kø-verktøylinje',
|
||||
queueToolbarReset: 'Tilbakestill til standard',
|
||||
queueToolbarSeparator: 'Skilje',
|
||||
@@ -497,15 +498,6 @@ export const settings = {
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
fsPlayerSection: 'Fullskjermspiller',
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
fsLyricsStyle: 'Tekststil',
|
||||
fsLyricsStyleRail: 'Skinner',
|
||||
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
|
||||
fsLyricsStyleApple: 'Rulling',
|
||||
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
|
||||
sidebarLyricsStyle: 'Rullestil for tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassisk',
|
||||
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Snelheid',
|
||||
miniPlayer: 'Minispeler',
|
||||
lyrics: 'Songtekst',
|
||||
fsNowPlaying: 'Wordt afgespeeld…',
|
||||
fsTrackPosition: 'Track {{current}} / {{total}}',
|
||||
fsNext: 'Volgende',
|
||||
fsUpNext: 'Hierna',
|
||||
fsQueueEmpty: 'Niets in de wachtrij.',
|
||||
shuffle: 'Shuffle',
|
||||
fsLyricsToggle: 'Songtekst in volledig scherm',
|
||||
lyricsLoading: 'Songtekst laden…',
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Wachtrij',
|
||||
modePlaylist: 'Afspeellijst',
|
||||
modeTimeline: 'Tijdlijn',
|
||||
upNext: 'Hierna',
|
||||
history: 'Geschiedenis',
|
||||
noUpcoming: 'Geen volgende nummers.',
|
||||
savePlaylist: 'Afspeellijst opslaan',
|
||||
updatePlaylist: 'Afspeellijst bijwerken',
|
||||
|
||||
@@ -327,6 +327,7 @@ export const settings = {
|
||||
queueModeTitle: 'Weergavemodus wachtrij',
|
||||
queueModeQueueSub: 'Toont alleen komende nummers. Het huidige nummer blijft in de kop en verdwijnt uit de lijst zodra het is afgespeeld.',
|
||||
queueModePlaylistSub: 'Houdt de hele wachtrij in de lijst met het huidige nummer bovenaan gemarkeerd; afgespeelde nummers blijven staan.',
|
||||
queueModeTimelineSub: 'Centreer de huidige track, met de afgespeelde geschiedenis erboven en de komende tracks (in shuffle-volgorde) eronder.',
|
||||
queueToolbarTitle: 'Wachtrij-werkbalk',
|
||||
queueToolbarReset: 'Standaard herstellen',
|
||||
queueToolbarSeparator: 'Scheiding',
|
||||
@@ -498,15 +499,6 @@ export const settings = {
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
fsPlayerSection: 'Volledig scherm speler',
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
fsLyricsStyle: 'Tekststijl',
|
||||
fsLyricsStyleRail: 'Rail',
|
||||
fsLyricsStyleRailDesc: 'Klassieke 5-regels schuifrail.',
|
||||
fsLyricsStyleApple: 'Scrollen',
|
||||
fsLyricsStyleAppleDesc: 'Volledig scherm scrolllijst.',
|
||||
sidebarLyricsStyle: 'Scrollstijl voor tekst',
|
||||
sidebarLyricsStyleClassic: 'Klassiek',
|
||||
sidebarLyricsStyleClassicDesc: 'Actieve regel wordt gecentreerd.',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Viteză',
|
||||
miniPlayer: 'Player Mini',
|
||||
lyrics: 'Versuri',
|
||||
fsNowPlaying: 'Se redă acum…',
|
||||
fsTrackPosition: 'Piesa {{current}} / {{total}}',
|
||||
fsNext: 'Următoarea',
|
||||
fsUpNext: 'Urmează',
|
||||
fsQueueEmpty: 'Nimic în coadă.',
|
||||
shuffle: 'Amestecă',
|
||||
fsLyricsToggle: 'Versuri în ecran complet',
|
||||
lyricsLoading: 'Se încarcă versurile…',
|
||||
lyricsNotFound: 'Niciun vers găsit pentru această piesă',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Coadă',
|
||||
modePlaylist: 'Playlist',
|
||||
modeTimeline: 'Cronologie',
|
||||
upNext: 'Urmează',
|
||||
history: 'Istoric',
|
||||
noUpcoming: 'Nicio piesă în continuare.',
|
||||
savePlaylist: 'Salvează Playlist',
|
||||
updatePlaylist: 'Actualizează Playlist',
|
||||
|
||||
@@ -332,6 +332,7 @@ export const settings = {
|
||||
queueModeTitle: 'Mod de afișare a cozii',
|
||||
queueModeQueueSub: 'Arată doar piesele următoare. Piesa curentă rămâne în antet și iese din listă după ce a fost redată.',
|
||||
queueModePlaylistSub: 'Păstrează toată coada în listă, cu piesa curentă evidențiată sus; piesele redate rămân.',
|
||||
queueModeTimelineSub: 'Centrează piesa curentă, cu istoricul redat deasupra și piesele următoare (în ordine aleatorie) dedesubt.',
|
||||
queueToolbarTitle: 'Toolbar Coadă',
|
||||
queueToolbarReset: 'Resetează la implicit',
|
||||
queueToolbarSeparator: 'Separator',
|
||||
@@ -503,20 +504,11 @@ export const settings = {
|
||||
infiniteQueue: 'Coadă Infinită',
|
||||
infiniteQueueDesc: 'Adaugă automat piese aleatorii când coada se golește',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Player pe ecran complet',
|
||||
fsLyricsStyle: 'Stilul versurilor',
|
||||
fsLyricsStyleRail: 'Șină',
|
||||
fsLyricsStyleRailDesc: 'Șină clasica pe 5 linii.',
|
||||
fsLyricsStyleApple: 'Derulare',
|
||||
fsLyricsStyleAppleDesc: 'Listă în derulare pe ecran complet.',
|
||||
sidebarLyricsStyle: 'Stilul derulării versurilor',
|
||||
sidebarLyricsStyleClassic: 'Clasic',
|
||||
sidebarLyricsStyleClassicDesc: 'Derulează linia activă în centru.',
|
||||
sidebarLyricsStyleApple: 'Ca Apple Music',
|
||||
sidebarLyricsStyleAppleDesc: 'Linia activă este ancorată aproape de vârf cu animații netede.',
|
||||
fsShowArtistPortrait: 'Arată poza artisului',
|
||||
fsShowArtistPortraitDesc: 'Arată poza artistului (sau arta albumului) în partea dreaptă a playerului pe ecran complet.',
|
||||
fsPortraitDim: 'Estomparea fotografiei',
|
||||
seekbarStyle: 'Stilul barei de redare',
|
||||
seekbarStyleDesc: 'Alege aspectul barei de redare a playerului',
|
||||
seekbarTruewave: 'Truewave',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: 'Скорость',
|
||||
miniPlayer: 'Мини-плеер',
|
||||
lyrics: 'Текст',
|
||||
fsNowPlaying: 'Сейчас играет…',
|
||||
fsTrackPosition: 'Трек {{current}} / {{total}}',
|
||||
fsNext: 'Далее',
|
||||
fsUpNext: 'Далее',
|
||||
fsQueueEmpty: 'Очередь пуста.',
|
||||
shuffle: 'Перемешать',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsNoSources: 'Источники текста не выбраны — включите их в Настройки → Текст',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: 'Очередь',
|
||||
modePlaylist: 'Плейлист',
|
||||
modeTimeline: 'Хронология',
|
||||
upNext: 'Далее',
|
||||
history: 'История',
|
||||
noUpcoming: 'Нет следующих треков.',
|
||||
savePlaylist: 'Сохранить как плейлист',
|
||||
updatePlaylist: 'Обновить плейлист',
|
||||
|
||||
@@ -408,6 +408,7 @@ export const settings = {
|
||||
queueModeTitle: 'Режим отображения очереди',
|
||||
queueModeQueueSub: 'Показывает только предстоящие треки. Текущий трек остаётся в заголовке и исчезает из списка после воспроизведения.',
|
||||
queueModePlaylistSub: 'Сохраняет всю очередь в списке, текущий трек выделен вверху; воспроизведённые треки остаются.',
|
||||
queueModeTimelineSub: 'Центрировать текущий трек: история воспроизведения сверху, предстоящие треки (в порядке перемешивания) снизу.',
|
||||
queueToolbarTitle: 'Панель инструментов очереди',
|
||||
queueToolbarReset: 'Сбросить',
|
||||
queueToolbarSeparator: 'Разделитель',
|
||||
@@ -609,15 +610,6 @@ export const settings = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
fsPlayerSection: 'Полноэкранный плеер',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
fsLyricsStyle: 'Стиль отображения текста',
|
||||
fsLyricsStyleRail: 'Рельс',
|
||||
fsLyricsStyleRailDesc: 'Классическая рамка из 5 строк.',
|
||||
fsLyricsStyleApple: 'Прокрутка',
|
||||
fsLyricsStyleAppleDesc: 'Полноэкранный список с прокруткой.',
|
||||
sidebarLyricsStyle: 'Стиль прокрутки текста',
|
||||
sidebarLyricsStyleClassic: 'Классический',
|
||||
sidebarLyricsStyleClassicDesc: 'Активная строка центрируется.',
|
||||
|
||||
@@ -48,6 +48,12 @@ export const player = {
|
||||
playbackRate: '速度',
|
||||
miniPlayer: '迷你播放器',
|
||||
lyrics: '歌词',
|
||||
fsNowPlaying: '正在播放…',
|
||||
fsTrackPosition: '曲目 {{current}} / {{total}}',
|
||||
fsNext: '下一首',
|
||||
fsUpNext: '即将播放',
|
||||
fsQueueEmpty: '队列为空。',
|
||||
shuffle: '随机播放',
|
||||
fsLyricsToggle: '全屏歌词',
|
||||
lyricsLoading: '正在加载歌词…',
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const queue = {
|
||||
title: '队列',
|
||||
modePlaylist: '播放列表',
|
||||
modeTimeline: '时间轴',
|
||||
upNext: '即将播放',
|
||||
history: '历史',
|
||||
noUpcoming: '没有后续曲目。',
|
||||
savePlaylist: '保存为播放列表',
|
||||
updatePlaylist: '更新播放列表',
|
||||
|
||||
@@ -326,6 +326,7 @@ export const settings = {
|
||||
queueModeTitle: '队列显示模式',
|
||||
queueModeQueueSub: '仅显示即将播放的曲目。当前曲目保留在标题栏中,播放完毕后从列表移除。',
|
||||
queueModePlaylistSub: '在列表中保留整个队列,当前曲目在顶部高亮显示;已播放的曲目保留。',
|
||||
queueModeTimelineSub: '将当前曲目居中显示,已播放的历史在上方,即将播放的曲目(按随机顺序)在下方。',
|
||||
queueToolbarTitle: '队列工具栏',
|
||||
queueToolbarReset: '重置为默认',
|
||||
queueToolbarSeparator: '分隔符',
|
||||
@@ -497,15 +498,6 @@ export const settings = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
fsPlayerSection: '全屏播放器',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
fsLyricsStyle: '歌词显示样式',
|
||||
fsLyricsStyleRail: '轨道',
|
||||
fsLyricsStyleRailDesc: '经典5行滑动轨道。',
|
||||
fsLyricsStyleApple: '滚动',
|
||||
fsLyricsStyleAppleDesc: '全屏可滚动列表。',
|
||||
sidebarLyricsStyle: '歌词滚动样式',
|
||||
sidebarLyricsStyleClassic: '经典',
|
||||
sidebarLyricsStyleClassicDesc: '活动行居中显示。',
|
||||
|
||||
@@ -63,7 +63,6 @@ describe('trivial pass-through setters', () => {
|
||||
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
|
||||
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
|
||||
['setNowPlayingEnabled', 'nowPlayingEnabled', true],
|
||||
['setShowFullscreenLyrics', 'showFullscreenLyrics', false],
|
||||
['setLyricsStaticOnly', 'lyricsStaticOnly', true],
|
||||
['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false],
|
||||
['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true],
|
||||
@@ -84,7 +83,6 @@ describe('trivial pass-through setters', () => {
|
||||
['setHotCacheMaxMb', 'hotCacheMaxMb', 1024],
|
||||
['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60],
|
||||
['setPreloadCustomSeconds', 'preloadCustomSeconds', 45],
|
||||
['setFsPortraitDim', 'fsPortraitDim', 64],
|
||||
])('%s stores a numeric value', (setter, key, value) => {
|
||||
(useAuthStore.getState() as unknown as Record<string, (v: unknown) => void>)[setter](value);
|
||||
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[key]).toBe(value);
|
||||
@@ -295,15 +293,12 @@ describe('lyrics source setters', () => {
|
||||
expect(useAuthStore.getState().lyricsSources).toEqual(sources);
|
||||
});
|
||||
|
||||
it('setYouLyPlusEnabled + setFsLyricsStyle + setSidebarLyricsStyle write values through', () => {
|
||||
it('setYouLyPlusEnabled + setSidebarLyricsStyle write values through', () => {
|
||||
useAuthStore.getState().setYouLyPlusEnabled(true);
|
||||
expect(useAuthStore.getState().youLyPlusEnabled).toBe(true);
|
||||
useAuthStore.getState().setYouLyPlusEnabled(false);
|
||||
expect(useAuthStore.getState().youLyPlusEnabled).toBe(false);
|
||||
|
||||
useAuthStore.getState().setFsLyricsStyle('apple');
|
||||
expect(useAuthStore.getState().fsLyricsStyle).toBe('apple');
|
||||
|
||||
useAuthStore.getState().setSidebarLyricsStyle('apple');
|
||||
expect(useAuthStore.getState().sidebarLyricsStyle).toBe('apple');
|
||||
});
|
||||
|
||||
@@ -88,11 +88,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
lyricsSources: DEFAULT_LYRICS_SOURCES,
|
||||
youLyPlusEnabled: false,
|
||||
lyricsStaticOnly: false,
|
||||
showFullscreenLyrics: true,
|
||||
fsLyricsStyle: 'rail',
|
||||
sidebarLyricsStyle: 'classic',
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
advancedSettingsEnabled: false,
|
||||
|
||||
@@ -106,7 +106,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
|
||||
// Missing key (pre-feature persist) / garbage maps to 'queue' — the default
|
||||
// mode, which lists only upcoming tracks.
|
||||
const VALID_QUEUE_DISPLAY_MODES = new Set<string>(['playlist', 'queue']);
|
||||
const VALID_QUEUE_DISPLAY_MODES = new Set<string>(['playlist', 'queue', 'timeline']);
|
||||
const queueDisplayModeMigrated = VALID_QUEUE_DISPLAY_MODES.has(
|
||||
(state as { queueDisplayMode?: unknown }).queueDisplayMode as string,
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export type DurationMode = 'total' | 'remaining' | 'eta';
|
||||
* - `queue`: the list shows upcoming tracks only — the current track lives in
|
||||
* the header and drops out of the list once it has played.
|
||||
*/
|
||||
export type QueueDisplayMode = 'playlist' | 'queue';
|
||||
export type QueueDisplayMode = 'playlist' | 'queue' | 'timeline';
|
||||
export type LoggingMode = 'off' | 'normal' | 'debug';
|
||||
/**
|
||||
* Wall-clock format for ETA / sleep-timer labels. `'auto'` follows the user's
|
||||
@@ -173,14 +173,8 @@ export interface AuthState {
|
||||
* Honoured in both lyrics modes.
|
||||
*/
|
||||
lyricsStaticOnly: boolean;
|
||||
showFullscreenLyrics: boolean;
|
||||
/** 'rail' = classic 5-line sliding rail; 'apple' = full-screen scrolling list */
|
||||
fsLyricsStyle: 'rail' | 'apple';
|
||||
/** Sidebar lyrics scroll style: 'classic' = scrollIntoView center; 'apple' = scroll to 35% */
|
||||
sidebarLyricsStyle: 'classic' | 'apple';
|
||||
showFsArtistPortrait: boolean;
|
||||
/** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */
|
||||
fsPortraitDim: number;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
/** Reveals sub-sections marked `advanced` across all Settings tabs. */
|
||||
@@ -191,7 +185,9 @@ export interface AuthState {
|
||||
queueNowPlayingCollapsed: boolean;
|
||||
/** Queue header duration chip mode (cycle: total → remaining → ETA). */
|
||||
queueDurationDisplayMode: DurationMode;
|
||||
/** Queue panel render mode: full timeline (`playlist`) vs. upcoming-only (`queue`). */
|
||||
/** Queue panel render mode: full list from top (`playlist`), upcoming-only
|
||||
* (`queue`), or full list centered on the current track with history above
|
||||
* and up-next below (`timeline`). */
|
||||
queueDisplayMode: QueueDisplayMode;
|
||||
|
||||
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||
@@ -340,11 +336,7 @@ export interface AuthState {
|
||||
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
|
||||
setYouLyPlusEnabled: (v: boolean) => void;
|
||||
setLyricsStaticOnly: (v: boolean) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setFsLyricsStyle: (v: 'rail' | 'apple') => void;
|
||||
setSidebarLyricsStyle: (v: 'classic' | 'apple') => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setAdvancedSettingsEnabled: (v: boolean) => void;
|
||||
|
||||
@@ -27,11 +27,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
| 'setQueueNowPlayingCollapsed'
|
||||
| 'setQueueDurationDisplayMode'
|
||||
| 'setQueueDisplayMode'
|
||||
| 'setShowFullscreenLyrics'
|
||||
| 'setFsLyricsStyle'
|
||||
| 'setSidebarLyricsStyle'
|
||||
| 'setShowFsArtistPortrait'
|
||||
| 'setFsPortraitDim'
|
||||
| 'setShowChangelogOnUpdate'
|
||||
| 'setLastSeenChangelogVersion'
|
||||
| 'setAdvancedSettingsEnabled'
|
||||
@@ -52,11 +48,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }),
|
||||
setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }),
|
||||
setQueueDisplayMode: (v) => set({ queueDisplayMode: v }),
|
||||
setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }),
|
||||
setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }),
|
||||
setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }),
|
||||
setShowFsArtistPortrait: (v) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
setAdvancedSettingsEnabled: (v) => set({ advancedSettingsEnabled: v }),
|
||||
|
||||
@@ -1,416 +1,8 @@
|
||||
/* ─────────────────────────────────────────
|
||||
Fullscreen Player — Adaptive Portrait
|
||||
Fullscreen player — lyrics overlay (FsLyricsApple)
|
||||
Synced + plain scrollable list. Other old fullscreen-player
|
||||
styles were removed with the legacy player.
|
||||
──────────────────────────────────────────── */
|
||||
.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;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(6, 6, 14, 0.88) 0%,
|
||||
rgba(6, 6, 14, 0.65) 32%,
|
||||
rgba(6, 6, 14, 0.18) 58%,
|
||||
transparent 78%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* ── Apple Music-style fullscreen lyrics ─────────────────────────────────────
|
||||
Full-screen scrollable list. Active line scrolls to ~35% from top.
|
||||
@@ -533,424 +125,3 @@
|
||||
margin: 0 0 0.2em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-popup {
|
||||
position: absolute;
|
||||
width: 340px;
|
||||
height: 480px;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.chat-popup-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-message-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-message-row.me {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-row.other {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-message-bubble {
|
||||
max-width: 85%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message-row.me .chat-message-bubble {
|
||||
background: var(--accent);
|
||||
color: var(--mantle);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-message-row.other .chat-message-bubble {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-message-author {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
font-size: 0.7rem;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-popup-input {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface1);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-popup-input input {
|
||||
flex: 1;
|
||||
background: var(--crust);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-popup-input input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-popup-input button {
|
||||
background: var(--accent);
|
||||
color: var(--mantle);
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.chat-popup-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
/* ── Static fullscreen player (lean, media-center layout) ──────────────────
|
||||
No backdrop-filter, no blur, no infinite animations. Background is a sharp
|
||||
image; the darkening gradient + vignette are plain static overlays. Only the
|
||||
seekbar and clock update at runtime, and they own their state. */
|
||||
|
||||
.fsp {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: #06060e;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
--fsp-cover-size: clamp(150px, 24vh, 250px);
|
||||
}
|
||||
|
||||
.fsp[data-idle='true'] {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
/* ── Background ── */
|
||||
.fsp-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* Bias toward the upper third so faces survive a 16:9 crop of square art. */
|
||||
object-position: center 30%;
|
||||
}
|
||||
|
||||
.fsp-bg--empty {
|
||||
background: radial-gradient(circle at 50% 35%, #1a1a2e, #06060e 70%);
|
||||
}
|
||||
|
||||
/* Static text-contrast scrim + edge vignette — baked look, no runtime effect. */
|
||||
.fsp-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 38%),
|
||||
linear-gradient(to right, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 55%),
|
||||
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 22%);
|
||||
}
|
||||
|
||||
.fsp-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse 75% 75% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.fsp-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
padding-right: calc(var(--space-6) + 52px); /* leave room for the close button */
|
||||
}
|
||||
|
||||
.fsp-nowplaying {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fsp-nowplaying-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.fsp-nowplaying-pos {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fsp-clock {
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Close (fades when idle) ── */
|
||||
.fsp-close {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base), background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.fsp[data-idle='false'] .fsp-close,
|
||||
.fsp:hover .fsp-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fsp-close:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Bottom cluster ── */
|
||||
.fsp-foot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6) var(--space-5);
|
||||
/* Soft backing for the control row only — the text has its own hard bar. */
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.18) 26%, rgba(0, 0, 0, 0) 52%);
|
||||
}
|
||||
|
||||
/* The black bar: hugs the text + cover with equal padding above and below.
|
||||
The cover is bottom-aligned to the text and pokes above the bar's top. */
|
||||
.fsp-info-row {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
/* Span the full width (cancel the foot's side padding); text stays inset. */
|
||||
margin: 0 calc(-1 * var(--space-6));
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Big cover, bottom-flush with the text block; its top pokes above the bar. */
|
||||
.fsp-cover {
|
||||
position: absolute;
|
||||
left: var(--space-6);
|
||||
bottom: var(--space-4);
|
||||
z-index: 1;
|
||||
width: var(--fsp-cover-size);
|
||||
height: var(--fsp-cover-size);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.fsp-cover-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fsp-cover-img--empty {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.fsp-info-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
/* Clear the overlapping cover on the left. */
|
||||
padding-left: calc(var(--fsp-cover-size) + var(--space-5));
|
||||
}
|
||||
|
||||
.fsp-title {
|
||||
margin: 0;
|
||||
font-size: 46px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.fsp-artist {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.fsp-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.fsp-meta-dot {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.fsp-stars {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.fsp-star-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
transition: transform 80ms ease;
|
||||
}
|
||||
|
||||
.fsp-star-btn:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.fsp-next {
|
||||
margin: 3px 0 0;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ── Controls row: transport · time · actions ── */
|
||||
.fsp-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
/* Own semi-transparent bar, a bit darker than the info bar above for contrast.
|
||||
Full-width: cancel the foot's side padding like .fsp-info-row. Negative top
|
||||
margin cancels the foot's flex gap so this bar touches the info bar above
|
||||
(the gap to the waveform below stays). */
|
||||
margin: calc(-1 * var(--space-4)) calc(-1 * var(--space-6)) 0;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.fsp-transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.fsp-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.fsp-time {
|
||||
justify-self: center;
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fsp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.fsp-btn-sm {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fsp-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fsp-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Bottom-right action cluster: larger tap targets than the transport's small buttons. */
|
||||
.fsp-actions .fsp-btn-sm {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
/* Reused FsPlayBtn (renders .fs-btn.fs-btn-play) — scope its look to this player. */
|
||||
.fsp .playback-transport-play-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Match the plain transport buttons — no white circle. */
|
||||
.fsp .fs-btn-play {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.fsp .fs-btn-play:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── True waveform seekbar (WaveformSeek) — full-width media-center bar ──
|
||||
Replaces the old thin FsSeekbar. Time stays centered in the control row
|
||||
(FsTimeReadout). The taller canvas makes the foot grow upward, shifting the
|
||||
info/control rows up. */
|
||||
.fsp .waveform-seek-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* WaveformSeek hardcodes a 24px canvas inline for the player bar; override it
|
||||
here for the fullscreen view. Its ResizeObserver redraws at the new size. */
|
||||
.fsp .waveform-seek-container canvas {
|
||||
height: clamp(32px, 5vh, 52px) !important;
|
||||
}
|
||||
|
||||
/* ── "Up next" overlay (FsQueueModal) — semi-transparent popover anchored
|
||||
bottom-right at the queue button, no blur. Transparent backdrop just catches
|
||||
the outside click to close. ── */
|
||||
.fsq-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fsq-panel {
|
||||
position: absolute;
|
||||
right: var(--space-6);
|
||||
bottom: 163px; /* flush with the top of the black info frame */
|
||||
width: min(420px, 40%);
|
||||
max-height: 62vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(18, 18, 22, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fsq-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fsq-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fsq-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fsq-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fsq-list {
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.fsq-empty {
|
||||
padding: var(--space-5);
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fsq-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-5);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fsq-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fsq-item-pos {
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.fsq-item-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.fsq-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fsq-item-artist {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.fsq-item-dur {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ── Scrolling lyrics overlay (reuses FsLyricsApple) — semi-transparent, no blur.
|
||||
FsLyricsApple's .fsa-lyrics-container is position:absolute inset:0, so it fills
|
||||
this box. ── */
|
||||
.fsp-lyrics-overlay {
|
||||
position: absolute;
|
||||
top: var(--space-6);
|
||||
left: 14%;
|
||||
right: 14%;
|
||||
bottom: 360px; /* ends above the song-info / cover block; tune */
|
||||
z-index: 4;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Centered lyrics text in the fullscreen overlay (FsLyricsApple defaults to
|
||||
left-aligned for the old layout). */
|
||||
.fsp-lyrics-overlay .fsa-lyrics-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fsp-lyrics-overlay .fsa-lyric-line {
|
||||
text-align: center;
|
||||
transform-origin: center center;
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
@import './login-page.css';
|
||||
@import './loading-empty.css';
|
||||
@import './fullscreen-player-adaptive-portrait.css';
|
||||
@import './fullscreen-player-static.css';
|
||||
@import './context-menu.css';
|
||||
@import './css-tooltips.css';
|
||||
@import './playlists-page.css';
|
||||
|
||||
Reference in New Issue
Block a user