feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-06 02:00:48 +02:00
parent a32ca64792
commit 8cd4cdcd64
26 changed files with 935 additions and 434 deletions
+7
View File
@@ -314,6 +314,13 @@ function TauriEventBridge() {
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+18 -7
View File
@@ -1,5 +1,6 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
@@ -442,7 +443,6 @@ export async function uploadPlaylistCoverArt(id: string, file: File): Promise<vo
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
const { invoke } = await import('@tauri-apps/api/core');
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
@@ -453,6 +453,23 @@ export async function uploadPlaylistCoverArt(id: string, file: File): Promise<vo
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
@@ -527,7 +544,6 @@ export async function uploadRadioCoverArt(id: string, file: File): Promise<void>
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
const { invoke } = await import('@tauri-apps/api/core');
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
@@ -543,7 +559,6 @@ export async function deleteRadioCoverArt(id: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const { invoke } = await import('@tauri-apps/api/core');
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
@@ -556,7 +571,6 @@ export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[],
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const { invoke } = await import('@tauri-apps/api/core');
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
@@ -580,18 +594,15 @@ function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBro
export const RADIO_PAGE_SIZE = 25;
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
+1 -2
View File
@@ -199,11 +199,10 @@ export default function AlbumHeader({
</div>
<button
className="btn btn-ghost"
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+1 -2
View File
@@ -252,10 +252,9 @@ export default function AlbumTrackList({
return (
<div key="favorite" className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
+121 -122
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
ChevronDown, Repeat, Repeat1, Square, Music, Heart
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -16,45 +15,8 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{title}
</span>
</div>
);
}
// ─── Crossfading blurred background ───────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
// ─── Artist portrait — right half, crossfades on track change ─────────────────
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 }] : []
);
@@ -64,8 +26,6 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
if (!url) return;
let cancelled = false;
const id = counterRef.current++;
// Preload the image before starting the crossfade — prevents a blank flash
// between the old and new layer while the browser decodes the image.
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
@@ -75,29 +35,34 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
setTimeout(() => {
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
}, 800);
}, 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 => (
<div
<img
key={layer.id}
className="fs-bg"
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
aria-hidden="true"
src={layer.url}
className="fs-portrait"
style={{ opacity: layer.visible ? 1 : 0 }}
decoding="async"
loading="eager"
alt=""
/>
))}
</>
</div>
);
});
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
@@ -112,21 +77,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
aria-label="seek"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
@@ -150,28 +116,40 @@ interface FullscreenPlayerProps {
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);
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 starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// buildCoverArtUrl generates a new salt on every call — must be memoized
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// No fetchUrl fallback for the background — we only want stable blob URLs
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
// buildCoverArtUrl generates a new salt on every call — must be memoized.
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Fetch artist image for background — fall back to cover art if unavailable
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
@@ -184,7 +162,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => { cancelled = true; };
}, [currentTrack?.artistId]);
const bgUrl = artistBgUrl || resolvedCoverUrl;
const portraitUrl = artistBgUrl || resolvedCoverUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
@@ -192,84 +170,105 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const metaParts = [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 0animated 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, object-fit: contain */}
<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>
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<div className="fs-cover-wrap">
{/* Album art */}
<div className="fs-art-wrap">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverKey}
alt={`${currentTrack?.album} Cover`}
className="fs-cover"
className="fs-art"
/>
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
)}
</div>
<div className="fs-track-info">
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
].filter(Boolean).join(' · ')}
</span>
)}
</div>
{/* Artist — massive statement */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
<FsProgress duration={duration} />
{/* Track title — accent, light weight */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</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">
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
<Square size={14} fill="currentColor" />
<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')}>
<SkipBack size={20} />
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={20} />
<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' : ''}`}
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>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal 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>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+2 -2
View File
@@ -141,11 +141,11 @@ export default function PlayerBar() {
</div>
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
style={{ flexShrink: 0 }}
>
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
+2 -4
View File
@@ -404,13 +404,11 @@ export default function QueuePanel() {
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
+1 -1
View File
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+3 -29
View File
@@ -164,29 +164,10 @@ export default function ThemePicker({ value, onChange }: Props) {
return (
<button
key={t.id}
className="theme-card-btn"
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
@@ -208,14 +189,7 @@ export default function ThemePicker({ value, onChange }: Props) {
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{t.label}
</span>
</button>
+58
View File
@@ -172,6 +172,8 @@ const enTranslation = {
cacheOffline: 'Save discography offline',
offlineCached: 'Discography cached',
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
uploadImage: 'Upload artist image',
uploadImageError: 'Failed to upload image',
},
favorites: {
title: 'Favorites',
@@ -414,6 +416,8 @@ const enTranslation = {
offlineDirHint: 'New downloads will use this location. Existing downloads remain at their original path.',
showArtistImages: 'Show Artist Images',
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
showTrayIcon: 'Show Tray Icon',
showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.',
minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
discordRichPresence: 'Discord Rich Presence',
@@ -498,6 +502,12 @@ const enTranslation = {
notWithCrossfade: 'Not available while Crossfade is active',
gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
preloadMode: 'Preload Next Track',
preloadModeDesc: 'When to start buffering the next track in the queue',
preloadBalanced: 'Balanced (30 s before end)',
preloadEarly: 'Early (after 5 s of playback)',
preloadCustom: 'Custom',
preloadCustomSeconds: 'Seconds before end: {{n}}',
infiniteQueue: 'Infinite Queue',
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
experimental: 'Experimental',
@@ -951,6 +961,8 @@ const deTranslation = {
cacheOffline: 'Diskografie offline speichern',
offlineCached: 'Diskografie gecacht',
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
uploadImage: 'Künstlerbild hochladen',
uploadImageError: 'Bild konnte nicht hochgeladen werden',
},
favorites: {
title: 'Favoriten',
@@ -1193,6 +1205,8 @@ const deTranslation = {
offlineDirHint: 'Neue Downloads werden an diesem Ort gespeichert. Bestehende Downloads verbleiben am ursprünglichen Pfad.',
showArtistImages: 'Künstlerbilder anzeigen',
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
showTrayIcon: 'Tray-Icon anzeigen',
showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.',
minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
discordRichPresence: 'Discord Rich Presence',
@@ -1277,6 +1291,12 @@ const deTranslation = {
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
preloadMode: 'Nächsten Track vorpuffern',
preloadModeDesc: 'Wann mit dem Puffern des nächsten Tracks begonnen werden soll',
preloadBalanced: 'Ausgewogen (30 s vor Ende)',
preloadEarly: 'Früh (nach 5 s Wiedergabe)',
preloadCustom: 'Benutzerdefiniert',
preloadCustomSeconds: 'Sekunden vor Ende: {{n}}',
infiniteQueue: 'Endlose Warteschlange',
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
experimental: 'Experimentell',
@@ -1730,6 +1750,8 @@ const frTranslation = {
cacheOffline: 'Enregistrer la discographie hors ligne',
offlineCached: 'Discographie en cache',
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
uploadImage: "Téléverser l'image de l'artiste",
uploadImageError: "Échec du téléversement de l'image",
},
favorites: {
title: 'Favoris',
@@ -1972,6 +1994,8 @@ const frTranslation = {
offlineDirHint: 'Les nouveaux téléchargements utiliseront cet emplacement. Les téléchargements existants restent à leur emplacement d\'origine.',
showArtistImages: 'Afficher les images d\'artistes',
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
showTrayIcon: 'Afficher l\'icône dans la barre système',
showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
discordRichPresence: 'Discord Rich Presence',
@@ -2056,6 +2080,12 @@ const frTranslation = {
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
gapless: 'Lecture sans blanc',
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
preloadMode: 'Précharger la piste suivante',
preloadModeDesc: 'Quand commencer à mettre en mémoire tampon la piste suivante',
preloadBalanced: 'Équilibré (30 s avant la fin)',
preloadEarly: 'Tôt (après 5 s de lecture)',
preloadCustom: 'Personnalisé',
preloadCustomSeconds: 'Secondes avant la fin : {{n}}',
infiniteQueue: 'File infinie',
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
experimental: 'Expérimental',
@@ -2509,6 +2539,8 @@ const nlTranslation = {
cacheOffline: 'Discografie offline opslaan',
offlineCached: 'Discografie gecached',
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
uploadImage: 'Artiestafbeelding uploaden',
uploadImageError: 'Uploaden van afbeelding mislukt',
},
favorites: {
title: 'Favorieten',
@@ -2751,6 +2783,8 @@ const nlTranslation = {
offlineDirHint: 'Nieuwe downloads worden op deze locatie opgeslagen. Bestaande downloads blijven op hun oorspronkelijke locatie.',
showArtistImages: 'Artiestafbeeldingen weergeven',
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
showTrayIcon: 'Tray-pictogram weergeven',
showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
discordRichPresence: 'Discord Rich Presence',
@@ -2835,6 +2869,12 @@ const nlTranslation = {
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
gapless: 'Naadloos afspelen',
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
preloadMode: 'Volgend nummer vooraf laden',
preloadModeDesc: 'Wanneer met het bufferen van het volgende nummer wordt begonnen',
preloadBalanced: 'Gebalanceerd (30 s voor einde)',
preloadEarly: 'Vroeg (na 5 s afspelen)',
preloadCustom: 'Aangepast',
preloadCustomSeconds: 'Seconden voor einde: {{n}}',
infiniteQueue: 'Oneindige wachtrij',
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
experimental: 'Experimenteel',
@@ -3288,6 +3328,8 @@ const zhTranslation = {
cacheOffline: '离线保存全部专辑',
offlineCached: '全部专辑已缓存',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
uploadImage: '上传艺术家图片',
uploadImageError: '图片上传失败',
},
favorites: {
title: '收藏夹',
@@ -3526,6 +3568,8 @@ const zhTranslation = {
offlineDirHint: '新下载将保存到此位置,现有下载保留在原始路径。',
showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
showTrayIcon: '显示托盘图标',
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
discordRichPresence: 'Discord Rich Presence',
@@ -3610,6 +3654,12 @@ const zhTranslation = {
notWithCrossfade: '交叉淡入淡出开启时不可用',
gapless: '无缝播放',
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
preloadMode: '预加载下一曲目',
preloadModeDesc: '何时开始缓冲队列中的下一曲目',
preloadBalanced: '均衡(结束前30秒)',
preloadEarly: '提前(播放5秒后)',
preloadCustom: '自定义',
preloadCustomSeconds: '结束前秒数:{{n}}',
infiniteQueue: '无限队列',
infiniteQueueDesc: '队列播完时自动追加随机曲目',
experimental: '实验性',
@@ -4063,6 +4113,8 @@ const nbTranslation = {
cacheOffline: 'Lagre diskografi som frakoblet',
offlineCached: 'Diskografi bufret',
offlineDownloading: 'Bufrer… ({{done}}/{{total}} album)',
uploadImage: 'Last opp artistbilde',
uploadImageError: 'Kunne ikke laste opp bildet',
},
favorites: {
title: 'Favoritter',
@@ -4393,6 +4445,12 @@ const nbTranslation = {
infiniteQueue: 'Uendelig kø',
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
experimental: 'Eksperimentell',
preloadMode: 'Forhåndslast neste spor',
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
preloadBalanced: 'Balansert (30 s før slutt)',
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
},
changelog: {
modalTitle: "Nyheter",
+1 -1
View File
@@ -99,7 +99,7 @@ export default function Albums() {
const clearYear = () => { setYearFrom(''); setYearTo(''); };
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
+53
View File
@@ -75,6 +75,13 @@ const CONTRIBUTORS = [
'Human-readable audio error messages (PR #44)',
],
},
{
github: 'zz5zz',
since: '1.32.0',
contributions: [
'Norwegian (Bokmål) translation (PR #101)',
],
},
] as const;
const SPECIAL_THANKS = [
@@ -450,6 +457,41 @@ export default function Settings() {
</label>
</div>
<div className="divider" />
{/* Preload mode */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
</div>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ width: 120 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</div>
</section>
@@ -467,6 +509,17 @@ export default function Settings() {
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
+12
View File
@@ -34,8 +34,11 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
preloadMode: 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
discordRichPresence: boolean;
nowPlayingEnabled: boolean;
@@ -71,8 +74,11 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
@@ -109,8 +115,11 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
discordRichPresence: false,
nowPlayingEnabled: false,
@@ -179,8 +188,11 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
+42 -3
View File
@@ -166,15 +166,44 @@ let togglePlayLock = false;
const radioAudio = new Audio();
radioAudio.preload = 'none';
let radioStopping = false;
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
function clearRadioReconnectTimer() {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
}
radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer();
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
});
radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; return; }
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error');
});
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
// Re-assign src to force a fresh connection, then resume playback.
const src = radioAudio.src;
radioAudio.src = src;
radioAudio.play().catch(console.error);
}, 4000);
});
// Waiting: browser is rebuffering — normal for live streams, no action needed.
radioAudio.addEventListener('waiting', () => {
console.debug('[psysonic] radio: buffering');
});
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
@@ -235,9 +264,15 @@ function handleAudioProgress(current_time: number, duration: number) {
}
}
// Pre-buffer / pre-chain next track when 30 s remain.
const { gaplessEnabled } = useAuthStore.getState();
if (dur - current_time < 30 && dur - current_time > 0) {
// Pre-buffer / pre-chain next track based on preload mode.
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
const remaining = dur - current_time;
const shouldPreload = preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0; // balanced (default)
if (shouldPreload) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
@@ -598,6 +633,7 @@ export const usePlayerStore = create<PlayerState>()(
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
@@ -614,6 +650,7 @@ export const usePlayerStore = create<PlayerState>()(
const { volume } = get();
++playGeneration;
isAudioPaused = false;
clearRadioReconnectTimer();
gaplessPreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// Stop Rust engine in case a regular track was playing.
@@ -623,6 +660,7 @@ export const usePlayerStore = create<PlayerState>()(
radioAudio.volume = volume;
radioAudio.play().catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
@@ -654,6 +692,7 @@ export const usePlayerStore = create<PlayerState>()(
// If a radio stream is active, stop it before the new track starts so
// the PlayerBar clears radio mode immediately and the stream is released.
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
+345 -190
View File
@@ -1628,10 +1628,17 @@
text-overflow: ellipsis;
}
.track-artist-link {
cursor: pointer;
}
.track-artist-link:hover {
color: var(--accent);
text-decoration: underline;
}
.track-artist-sep {
color: var(--text-muted);
user-select: none;
}
.track-codec {
display: block;
@@ -1684,6 +1691,26 @@
padding: 4px;
height: auto;
min-height: unset;
color: var(--text-muted);
transition: color var(--transition-fast);
}
.track-star-btn:hover,
.track-star-btn.is-starred {
color: var(--color-star-active, var(--accent));
}
.album-detail-star-btn {
transition: color var(--transition-fast), border-color var(--transition-fast) !important;
}
.album-detail-star-btn:hover {
color: var(--color-star-active, var(--accent)) !important;
}
.album-detail-star-btn.is-starred {
color: var(--color-star-active, var(--accent)) !important;
border: 1px solid var(--color-star-active, var(--accent)) !important;
}
/* ── Random Mix — clickable artist name ── */
@@ -2271,6 +2298,70 @@
opacity: 0.8;
}
/* ── Theme Card (individual theme preview button) ───────────────────────── */
.theme-card-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.theme-card-preview {
width: 100%;
height: 46px;
border-radius: 8px;
overflow: hidden;
outline: 2px solid transparent;
outline-offset: 2px;
position: relative;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: outline-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
/* Hover: dim accent ring + subtle lift */
.theme-card-btn:hover .theme-card-preview {
outline-color: var(--accent-glow);
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
transform: scale(1.04);
}
/* Active: full accent ring + glow */
.theme-card-preview.is-active {
outline-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow, rgba(0,0,0,0.2));
}
/* Active + hover: keep strong ring, keep lift */
.theme-card-btn:hover .theme-card-preview.is-active {
outline-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow, rgba(0,0,0,0.3));
transform: scale(1.04);
}
.theme-card-label {
font-size: 11px;
color: var(--text-secondary);
font-weight: 400;
text-align: center;
line-height: 1.2;
word-break: break-word;
transition: color var(--transition-fast), font-weight 0s;
}
.theme-card-btn:hover .theme-card-label {
color: var(--text-primary);
}
.theme-card-label.is-active {
color: var(--text-primary);
font-weight: 600;
}
/* ── Queue Tab Bar ────────────────────────────────────────────────────────── */
.queue-tab-bar {
@@ -2715,82 +2806,147 @@
}
/*
Fullscreen Player Ambient Stage
Fullscreen Player Adaptive Portrait
*/
.fs-player {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
will-change: transform;
contain: layout style paint;
animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
background: #0e0e1a;
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;
}
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* ── Cover breathing ── */
@keyframes ambilight-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1.0; }
/* ── Animated dark mesh — GPU-only: only transform3d, no scale, real divs for will-change ── */
@keyframes mesh-aura-a {
0% { transform: translate3d(0, 0, 0); }
33% { transform: translate3d(5%, -6%, 0); }
66% { transform: translate3d(-3%, 2%, 0); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes cover-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.018);
}
@keyframes mesh-aura-b {
0% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(-6%, 5%, 0); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes ken-burns {
0% { transform: scale(1.05) translate(-4%, -1%); }
25% { transform: scale(1.08) translate( 4%, -1.5%); }
50% { transform: scale(1.06) translate( 3%, 1%); }
75% { transform: scale(1.09) translate(-3%, 1.5%); }
100% { transform: scale(1.05) translate(-4%, -1%); }
@keyframes portrait-drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -10px, 0); }
}
/* ── Blurred background ── */
.fs-bg {
position: absolute;
inset: -20%;
background-size: cover;
background-position: center 20%;
filter: blur(4px) brightness(0.25) saturate(1.5);
animation: ken-burns 120s linear infinite;
z-index: 0;
will-change: transform;
pointer-events: none;
transition: opacity 700ms ease;
}
.fs-bg-overlay {
.fs-mesh-bg {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 0;
background: #06060e;
overflow: hidden;
pointer-events: none;
}
/* Blobs are real divs so will-change: transform applies (pseudo-elements can't have it) */
.fs-mesh-blob {
position: absolute;
border-radius: 50%;
pointer-events: none;
will-change: transform;
}
.fs-mesh-blob-a {
/* Oversized so panning never exposes the dark base underneath */
width: 130%;
height: 130%;
left: -35%;
bottom: -35%;
background: radial-gradient(ellipse, var(--accent-glow, rgba(120, 80, 255, 0.14)) 0%, transparent 65%);
filter: blur(55px);
animation: mesh-aura-a 26s ease-in-out infinite;
animation-delay: 350ms;
}
.fs-mesh-blob-b {
width: 100%;
height: 110%;
right: -25%;
top: -25%;
background: radial-gradient(ellipse, var(--accent-dim, rgba(120, 80, 255, 0.08)) 0%, transparent 65%);
filter: blur(65px);
animation: mesh-aura-b 20s ease-in-out infinite;
animation-delay: 350ms;
}
/* ── 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;
border-radius: 12px;
overflow: hidden;
transform: translateZ(0);
/* Fade left edge to blend into the dark mesh */
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
rgba(0, 0, 0, 0.45) 18%,
rgba(0, 0, 0, 0.85) 40%,
#000 60%
);
mask-image: linear-gradient(
to right,
transparent 0%,
rgba(0, 0, 0, 0.45) 18%,
rgba(0, 0, 0, 0.85) 40%,
#000 60%
);
}
.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;
}
/* ── 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;
@@ -2811,179 +2967,102 @@
}
.fs-close:hover {
background: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
transform: translateY(-1px);
}
/* ── Center stage ── */
.fs-stage {
position: relative;
z-index: 1;
/* ── Info cluster — bottom-left quadrant ── */
.fs-cluster {
position: absolute;
bottom: 72px;
left: clamp(28px, 4vw, 64px);
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
width: min(440px, 88vw);
padding: 16px 0;
will-change: transform;
gap: 12px;
max-width: 85vw;
}
/* Artist name — above cover */
.fs-artist {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin: 0;
opacity: 0.9;
}
/* Cover — breathes slowly */
.fs-cover-wrap {
width: clamp(300px, 30vw, 480px);
max-height: calc(100vh - 300px);
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
/* Album art — small, rounded, glowing */
.fs-art-wrap {
width: clamp(120px, 10vw, 180px);
height: clamp(120px, 10vw, 180px);
border-radius: 12px;
overflow: hidden;
box-shadow: none;
flex-shrink: 0;
animation: cover-breathe 9s ease-in-out infinite;
will-change: transform;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.07),
0 0 28px var(--accent-glow, var(--accent));
}
.fs-cover {
.fs-art {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fs-cover-placeholder {
.fs-art-placeholder {
width: 100%;
height: 100%;
background: var(--ctp-surface0);
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
color: rgba(255, 255, 255, 0.3);
}
/* Track info — below cover */
.fs-track-info {
text-align: center;
width: 100%;
}
.fs-title-wrap {
/* Artist name — massive, font-black, uppercase, wraps gracefully */
.fs-artist-name {
font-family: var(--font-display);
font-size: clamp(20px, 3vw, 32px);
font-weight: 800;
font-size: clamp(28px, 4.5vw, 68px);
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.02em;
color: #ffffff;
margin: 0 0 6px;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
}
.fs-title-marquee {
display: inline-block;
animation: marquee-scroll 14s linear infinite alternate;
}
@keyframes marquee-scroll {
0%,
15% {
transform: translateX(0);
}
85%,
100% {
transform: translateX(var(--scroll-amount, 0px));
}
}
.fs-album {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.05;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.7);
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.fs-codec {
display: inline-block;
font-size: 10px;
font-family: monospace;
letter-spacing: 0.04em;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: rgba(255, 255, 255, 0.35);
margin-top: 8px;
/* Track title — large, light, accent color, wraps gracefully */
.fs-track-title {
font-family: var(--font-display);
font-size: clamp(18px, 2.6vw, 40px);
font-weight: 300;
color: var(--accent);
margin: 0;
line-height: 1.2;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Progress bar */
.fs-progress-wrap {
/* Metadata row */
.fs-meta {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
gap: 6px;
flex-wrap: wrap;
font-size: clamp(11px, 1vw, 13px);
color: rgba(255, 255, 255, 0.5);
}
.fs-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: center;
.fs-meta-dot {
opacity: 0.4;
}
.fs-progress-bar {
flex: 1;
position: relative;
}
.fs-progress-bar input[type="range"] {
width: 100%;
height: 3px;
background: linear-gradient(to right,
rgba(255, 255, 255, 0.85) var(--pct, 0%),
rgba(255, 255, 255, 0.32) var(--pct, 0%),
rgba(255, 255, 255, 0.32) var(--buf, 0%),
rgba(255, 255, 255, 0.15) var(--buf, 0%));
border-radius: 2px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 13px;
height: 13px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
transition: transform var(--transition-fast);
}
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb {
transform: scale(1.3);
}
/* Transport controls */
/* Controls row — left-aligned */
.fs-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
gap: 6px;
margin-top: 4px;
}
.fs-btn {
@@ -2993,7 +3072,7 @@
width: 36px;
height: 36px;
border-radius: 50%;
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
transition: all var(--transition-fast);
background: transparent;
@@ -3002,6 +3081,7 @@
.fs-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
.fs-btn.active {
@@ -3014,19 +3094,94 @@
}
.fs-btn-play {
width: 54px;
height: 54px;
width: 52px;
height: 52px;
background: var(--accent);
color: var(--ctp-crust);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow, var(--accent));
}
.fs-btn-play:hover {
background: var(--accent);
color: var(--ctp-crust);
transform: scale(1.07);
transform: scale(1.1);
filter: brightness(1.12);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.6), 0 0 28px var(--accent-glow, var(--accent));
}
.fs-btn-heart:hover,
.fs-btn-heart.active {
color: 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(--accent);
box-shadow: 0 0 15px var(--accent-glow, var(--accent));
pointer-events: none;
}
.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;
}
/* Chat */
+22 -1
View File
@@ -735,6 +735,13 @@
/* Star + Last.fm heart — visually separated from track title */
.player-star-btn {
margin: var(--space-1);
color: var(--text-muted);
transition: color var(--transition-fast);
}
.player-star-btn:hover,
.player-star-btn.is-starred {
color: var(--color-star-active, var(--accent));
}
.player-btn-primary {
@@ -1025,7 +1032,7 @@
}
.queue-round-btn:hover:not(:disabled) {
background: var(--border);
color: var(--text-primary);
color: var(--accent);
}
.queue-round-btn:disabled {
opacity: 0.35;
@@ -1093,6 +1100,14 @@
.queue-current-sub {
font-size: 12px;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.queue-current-sub.is-link {
cursor: pointer;
}
.queue-current-sub.is-link:hover {
color: var(--accent);
}
.queue-current-tech {
@@ -1168,6 +1183,12 @@
.queue-item-artist {
font-size: 11px;
color: var(--text-muted);
transition: color var(--transition-fast);
}
.queue-item:hover .queue-item-artist,
.queue-item.context-active .queue-item-artist {
color: var(--text-secondary);
}
.queue-item-duration {
+20
View File
@@ -171,6 +171,26 @@ export async function getImageCacheSize(): Promise<number> {
}
}
/** Removes a single cache entry from both in-memory and IndexedDB caches. */
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
const existing = objectUrlCache.get(cacheKey);
if (existing) {
URL.revokeObjectURL(existing);
objectUrlCache.delete(cacheKey);
}
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(cacheKey);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore
}
}
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
export async function clearImageCache(): Promise<void> {
for (const url of objectUrlCache.values()) {