mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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 1 — blurred artist image */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
{/* 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, 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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') },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user