Compare commits

...

4 Commits

Author SHA1 Message Date
Psychotoxical bda2822db5 diag(audio): run device enumeration ~every 60s instead of 3s (#996, experiment)
EXPERIMENT BUILD ONLY — not for release. Diagnostic for the macOS ~3s
playback stutter (#996): keep the 3s watcher loop, watchdog and stall
detector unchanged, but run the heavy CoreAudio device enumeration only
every ~60s. If the stutter cadence shifts to ~60s (or stops) on the
reporter's machine, the periodic enumeration is the cause.
2026-06-05 15:47:59 +02:00
Frank Stellmacher 1c23305887 feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode

A third queue display mode alongside Queue and Playlist. Timeline shows
the full queue anchored on the current track — played history above
(dimmed), upcoming below — with 'History' and 'Up next' dividers and the
current row auto-centered. It already follows shuffle order since the
queue is stored in play order. The header mode button now cycles
queue → timeline → playlist; Settings gains a third option.

* i18n(queue): Timeline mode strings (9 locales)

* docs(changelog): note Timeline queue mode (#1004)
2026-06-05 16:19:20 +02:00
Frank Stellmacher 41157ccaca chore(fullscreen-player): drop dead i18n keys and old CSS (#1002)
Follow-up cleanup after the old fullscreen player was removed (#1001):
- remove the 9 now-unused settings i18n keys (fsPlayerSection,
  fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple
  +Descs)) across all 9 locales.
- reduce fullscreen-player-adaptive-portrait.css to the shared lyrics
  overlay styles (FsLyricsApple); drop the old player's .fs-*, the
  .fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes.
2026-06-05 14:45:03 +02:00
Frank Stellmacher dc4eef1a97 feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1)

New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.

* feat(fullscreen-player): true waveform seekbar instead of thin bar

Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.

* feat(fullscreen-player): up-next popover + control-bar styling

- Queue button opens a semi-transparent 'Up next' popover anchored bottom
  right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
  above) for contrast; play button matches the plain transport buttons
  (no white circle, same size).

* feat(fullscreen-player): high-res (2000px) background cover

Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.

* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish

- Lyrics button next to Queue toggles a centered, dark semi-transparent
  scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.

* feat(fullscreen-player): make rating stars clickable

The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.

* fix(fullscreen-player): stable, high-res album cover

The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.

It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.

* refactor(fullscreen-player): remove the old player and its settings

The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.

Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.

* i18n(fullscreen-player): translate the new player's strings

Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
58 changed files with 1110 additions and 1819 deletions
+19
View File
@@ -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(|| {
+1 -1
View File
@@ -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';
-188
View File
@@ -1,188 +0,0 @@
/**
* `FullscreenPlayer` characterization (Phase F5c).
*
* Includes the §4.5 regression test from the v2 plan — the cover image
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
* third argument selects the fallback path that avoids a double
* crossfade (fetchUrl → blobUrl). A refactor that "tidies up" the
* useCachedUrl call sites would silently regress the FS player.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
getLyricsBySongId: vi.fn(async () => null),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
// so we can assert the third positional arg `false` is preserved.
vi.mock('./CachedImage', async () => {
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
return {
...actual,
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
};
});
import FullscreenPlayer from './FullscreenPlayer';
import { useCachedUrl } from './CachedImage';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
vi.mocked(useCachedUrl).mockClear();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
afterEach(() => {
vi.mocked(useCachedUrl).mockClear();
});
describe('FullscreenPlayer — render', () => {
it('renders the labelled Fullscreen Player dialog', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
});
it('exposes the Close Fullscreen button', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
});
});
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
// The component calls `useCachedUrl` twice:
// - line 338: for the small art box (default behaviour, opt=true)
// - line 674: for the cover (opt=false, no fetchUrl fallback)
// The `false` arg is load-bearing — it avoids a double crossfade by
// routing through the cache-only path. Pin it.
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const coverCall = calls.find(c => c[2] === false);
expect(coverCall).toBeDefined();
expect(typeof coverCall?.[1]).toBe('string');
expect(String(coverCall?.[1])).toContain('album-1');
});
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const defaultOptCalls = calls.filter(c => c[2] !== false);
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
});
});
describe('FullscreenPlayer — control wiring', () => {
it('clicking Close Fullscreen calls the onClose prop', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const onClose = vi.fn();
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={onClose} />,
);
fireEvent.click(getByLabelText('Close Fullscreen'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 5 });
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 0,
currentTrack: makeTrack({ id: 'a' }),
});
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles via toggleRepeat', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
});
});
-228
View File
@@ -1,228 +0,0 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
import { FsArt } from './fullscreenPlayer/FsArt';
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
import { useFsIdleFade } from '../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../hooks/useQueueTracks';
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
const artUrl = artCover.src;
const artKey = artCover.cacheKey;
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
const coverUrl = portraitCover.src;
const coverKey = portraitCover.cacheKey;
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Dynamic accent color extracted from the current album cover, applied as
// --dynamic-fs-accent on the root element. Cache hits return instantly so
// same-album tracks reuse the color without re-fetching.
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
// Artist image → portrait on right. Falls back to cover art.
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
// (thin-state): the next ref resolves from the cache (the prefetch window
// around the current index keeps it warm).
const queueIndex = usePlayerStore(s => s.queueIndex);
const nextTrack = useQueueTrackAt(queueIndex + 1);
const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
nextTrack.albumId,
nextTrack.coverArt,
300,
);
getCachedBlob(url, key).catch(() => {});
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
const fsControlsRef = useRef<HTMLDivElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
const metaParts = useMemo(() => [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean), [currentTrack]);
return (
<div
className="fs-player"
role="dialog"
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
data-lyrics={isAppleMode || undefined}
onMouseMove={handleMouseMove}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
'--fs-portrait-dim': String(fsPortraitDim / 100),
} as React.CSSProperties}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Album art */}
<div className="fs-art-wrap">
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls" ref={fsControlsRef}>
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+5 -5
View File
@@ -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');
});
});
-54
View File
@@ -1,54 +0,0 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Music } from 'lucide-react';
import { useCachedUrl } from '../CachedImage';
// Album art box — crossfades layers so old art stays visible while new loads.
// Uses 300px thumbnails (portrait fallback uses 500px separately).
//
// Why onLoad instead of new Image() preload:
// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
// commit, so the browser never sees opacity:0 and the CSS transition never fires.
// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
// before we flip it to 1.
export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
// true = show raw fetchUrl immediately as fallback while blob resolves.
// PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
// Showing the URL directly avoids the multi-second blank wait.
const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
const [layers, setLayers] = useState<Array<{ src: string; id: number; vis: boolean }>>([]);
const counter = useRef(0);
const cleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!blobUrl) return;
const id = ++counter.current;
setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
}, [blobUrl]);
const handleLoad = useCallback((id: number) => {
if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
}, []);
if (layers.length === 0) {
return <div className="fs-art fs-art-placeholder"><Music size={40} /></div>;
}
return (
<>
{layers.map(l => (
<img
key={l.id}
src={l.src}
className="fs-art"
style={{ opacity: l.vis ? 1 : 0 }}
onLoad={() => handleLoad(l.id)}
alt=""
decoding="async"
/>
))}
</>
);
});
@@ -0,0 +1,35 @@
import { memo, useEffect, useState } from 'react';
function formatClock(): string {
return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
/**
* Standalone wall-clock for the fullscreen player. Owns its own state so the
* static player never re-renders for the time. Ticks every 30s (enough to catch
* the minute rollover) and pauses while the window/tab is hidden.
*/
export const FsClock = memo(function FsClock() {
const [time, setTime] = useState(formatClock);
useEffect(() => {
let id: number | undefined;
const tick = () => setTime(formatClock());
const sync = () => {
if (document.hidden) {
if (id !== undefined) { clearInterval(id); id = undefined; }
} else {
tick();
if (id === undefined) id = window.setInterval(tick, 30_000);
}
};
sync();
document.addEventListener('visibilitychange', sync);
return () => {
if (id !== undefined) clearInterval(id);
document.removeEventListener('visibilitychange', sync);
};
}, []);
return <span className="fsp-clock">{time}</span>;
});
@@ -1,77 +0,0 @@
import React, { memo, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
interface Props {
open: boolean;
onClose: () => void;
accentColor: string | null;
triggerRef?: React.RefObject<HTMLElement | null>;
}
// Lyrics settings popover — shown above the mic button.
export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
const { t } = useTranslation();
const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
const setStyle = useAuthStore(s => s.setFsLyricsStyle);
const panelRef = useRef<HTMLDivElement>(null);
// Close on click outside the panel or on Escape.
// Ignore clicks on the trigger button so re-clicking it toggles normally
// instead of outside-handler closing + click re-opening.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onMouse = (e: MouseEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (triggerRef?.current?.contains(target)) return;
onClose();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
return () => {
clearTimeout(t);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onMouse);
};
}, [open, onClose, triggerRef]);
if (!open) return null;
const accent = accentColor ?? 'var(--accent)';
return (
<div className="fslm-panel" ref={panelRef}>
<div className="fslm-row">
<span className="fslm-label">{t('player.fsLyricsToggle')}</span>
<label className="toggle-switch" aria-label={t('player.fsLyricsToggle')}>
<input
type="checkbox"
checked={showLyrics}
onChange={e => setLyrics(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
<div className={`fslm-style-row${showLyrics ? '' : ' fslm-disabled'}`}>
{(['rail', 'apple'] as const).map(style => (
<button
key={style}
className={`fslm-style-btn${lyricsStyle === style ? ' fslm-style-active' : ''}`}
onClick={() => setStyle(style)}
style={lyricsStyle === style ? { borderColor: accent, color: accent, background: `color-mix(in srgb, ${accent} 14%, transparent)` } : undefined}
>
<span className="fslm-style-name">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}</span>
<span className="fslm-style-desc">{t(`settings.fsLyricsStyle${style.charAt(0).toUpperCase() + style.slice(1)}Desc` as any)}</span>
</button>
))}
</div>
<div className="fslm-arrow" />
</div>
);
});
@@ -1,92 +0,0 @@
import React, { memo, useCallback, useEffect, useRef } from 'react';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
import { useLyrics, type WordLyricsLine } from '../../hooks/useLyrics';
import { useWordLyricsSync } from '../../hooks/useWordLyricsSync';
import type { LrcLine } from '../../api/lrclib';
import type { Track } from '../../store/playerStoreTypes';
// Classic 5-line rail lyrics (original "Rail" style).
// Slot height = 6vh = window.innerHeight * 0.06 — must match CSS height: 6vh.
export const FsLyricsRail = memo(function FsLyricsRail({ currentTrack }: { currentTrack: Track | null }) {
const { syncedLines, wordLines, loading } = useLyrics(currentTrack);
const staticOnly = useAuthStore(s => s.lyricsStaticOnly);
const useWords = !staticOnly && wordLines !== null && wordLines.length > 0;
const lineSrc: LrcLine[] | null = useWords
? (wordLines as WordLyricsLine[]).map(l => ({ time: l.time, text: l.text }))
: (syncedLines as LrcLine[] | null);
const hasSynced = !staticOnly && lineSrc !== null && lineSrc.length > 0;
const linesRef = useRef<LrcLine[]>([]);
linesRef.current = hasSynced ? lineSrc! : [];
const activeIdx = usePlayerStore(s => {
const ls = linesRef.current;
if (ls.length === 0) return -1;
return ls.reduce((acc, line, i) => s.currentTime >= line.time ? i : acc, -1);
});
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seek = usePlayerStore(s => s.seek);
const slotH = useRef(window.innerHeight * 0.06);
useEffect(() => {
const onResize = () => { slotH.current = window.innerHeight * 0.06; };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const handleLineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-time]');
if (!target || duration <= 0) return;
seek(parseFloat(target.dataset.time!) / duration);
}, [duration, seek]);
const { setWordRef } = useWordLyricsSync({
enabled: useWords,
wordLines: useWords ? (wordLines as WordLyricsLine[]) : null,
currentTrack,
classPrefix: 'fsr',
});
if (!currentTrack || loading || !hasSynced) return null;
const railY = (2 - Math.max(0, activeIdx)) * slotH.current;
return (
<div className="fsr-lyrics-overlay" aria-hidden="true">
<div
className="fsr-lyrics-rail"
style={{ transform: `translateY(${railY}px)` }}
onClick={handleLineClick}
>
{useWords
? (wordLines as WordLyricsLine[]).map((line, i) => (
<div
key={i}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.words.length > 0 ? line.words.map((w, j) => (
<span
key={j}
className="fsr-lyric-word"
ref={setWordRef(i, j)}
>{w.text}</span>
)) : (line.text || ' ')}
</div>
))
: lineSrc!.map((line, i) => (
<div
key={i}
className={`fsr-lyric-line${i === activeIdx ? ' fsrl-active' : i < activeIdx ? ' fsrl-past' : ''}`}
data-time={line.time}
>
{line.text || ' '}
</div>
))}
</div>
</div>
);
});
@@ -37,7 +37,7 @@ export const FsPlayBtn = memo(function FsPlayBtn({
: <Sunrise size={12} strokeWidth={2.5} />}
<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>
);
}
+18 -8
View File
@@ -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" }}>
+23 -1
View File
@@ -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 -36
View File
@@ -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} />}
+17 -2
View File
@@ -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>
-1
View File
@@ -64,7 +64,6 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
{ tab: 'appearance', titleKey: 'settings.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' },
+2
View File
@@ -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,
});
}
-43
View File
@@ -1,43 +0,0 @@
import { useEffect, useState } from 'react';
import { extractCoverColors } from '../utils/ui/dynamicColors';
// Module-level cache: artKey → accent color string.
// Survives track changes so same-album songs reuse the extracted color instantly.
const coverAccentCache = new Map<string, string>();
/** Extract a dominant accent color from the current cover art and cache it by
* artKey. Cache hits return instantly; cache misses fetch the cover blob,
* run extractCoverColors, then cache + apply the result. Keeps the previous
* color visible until extraction completes so the UI doesn't flash to default. */
export function useFsDynamicAccent(artUrl: string, artKey: string): string | null {
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
useEffect(() => {
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
const cached = coverAccentCache.get(artKey);
if (cached) { setDynamicAccent(cached); return; }
let cancelled = false;
let blobUrl = '';
(async () => {
try {
const resp = await fetch(artUrl);
if (cancelled) return;
const blob = await resp.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
const colors = await extractCoverColors(blobUrl);
if (cancelled) return;
if (colors.accent) {
coverAccentCache.set(artKey, colors.accent);
setDynamicAccent(colors.accent);
}
} catch { /* ignore */ } finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artKey]);
return dynamicAccent;
}
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Geschwindigkeit',
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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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.',
+6
View File
@@ -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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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',
+6
View File
@@ -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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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.',
+6
View File
@@ -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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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.",
+6
View File
@@ -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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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.',
+6
View File
@@ -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',
+3
View File
@@ -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',
+1 -9
View File
@@ -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.',
+6
View File
@@ -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ă',
+3
View File
@@ -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',
+1 -9
View File
@@ -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',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: 'Скорость',
miniPlayer: 'Мини-плеер',
lyrics: 'Текст',
fsNowPlaying: 'Сейчас играет…',
fsTrackPosition: 'Трек {{current}} / {{total}}',
fsNext: 'Далее',
fsUpNext: 'Далее',
fsQueueEmpty: 'Очередь пуста.',
shuffle: 'Перемешать',
lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден',
lyricsNoSources: 'Источники текста не выбраны — включите их в Настройки → Текст',
+3
View File
@@ -1,6 +1,9 @@
export const queue = {
title: 'Очередь',
modePlaylist: 'Плейлист',
modeTimeline: 'Хронология',
upNext: 'Далее',
history: 'История',
noUpcoming: 'Нет следующих треков.',
savePlaylist: 'Сохранить как плейлист',
updatePlaylist: 'Обновить плейлист',
+1 -9
View File
@@ -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: 'Активная строка центрируется.',
+6
View File
@@ -48,6 +48,12 @@ export const player = {
playbackRate: '速度',
miniPlayer: '迷你播放器',
lyrics: '歌词',
fsNowPlaying: '正在播放…',
fsTrackPosition: '曲目 {{current}} / {{total}}',
fsNext: '下一首',
fsUpNext: '即将播放',
fsQueueEmpty: '队列为空。',
shuffle: '随机播放',
fsLyricsToggle: '全屏歌词',
lyricsLoading: '正在加载歌词…',
lyricsNotFound: '未找到此曲目的歌词',
+3
View File
@@ -1,6 +1,9 @@
export const queue = {
title: '队列',
modePlaylist: '播放列表',
modeTimeline: '时间轴',
upNext: '即将播放',
history: '历史',
noUpcoming: '没有后续曲目。',
savePlaylist: '保存为播放列表',
updatePlaylist: '更新播放列表',
+1 -9
View File
@@ -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: '活动行居中显示。',
+1 -6
View File
@@ -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');
});
-4
View File
@@ -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,
+1 -1
View File
@@ -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,
)
+4 -12
View File
@@ -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 0100 (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;
-8
View File
@@ -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;
}
+1
View File
@@ -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';