feat: v1.14.0 — Critical Buffer Fix, Gapless/Crossfade stable, UX polish

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-22 23:00:55 +01:00
parent 5516d95b52
commit 361e9cfdb3
20 changed files with 197 additions and 190 deletions
+2
View File
@@ -103,6 +103,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
return (
<>
@@ -159,6 +160,7 @@ export default function AlbumHeader({
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{formatLabel && <span>· {formatLabel}</span>}
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
+13
View File
@@ -80,6 +80,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const album = albums[activeIdx] ?? null;
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
});
}, [album?.id]);
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
@@ -120,6 +132,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
{album.songCount && <span className="badge">{album.songCount} Tracks</span>}
{albumFormats[album.id] && <span className="badge">{albumFormats[album.id]}</span>}
</div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
+9 -9
View File
@@ -318,20 +318,19 @@ export default function QueuePanel() {
</div>
{currentTrack && (
<div className="queue-current-track">
<div className="queue-current-track" style={{ position: 'relative' }}>
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-tech">
{currentTrack.suffix?.toUpperCase() ?? ''}
{currentTrack.bitRate ? ` · ${currentTrack.bitRate}` : ''}
</div>
)}
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
: currentTrack.suffix?.toUpperCase() ?? ''}
</div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
@@ -370,7 +369,7 @@ export default function QueuePanel() {
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => setGaplessEnabled(!gaplessEnabled)}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
@@ -385,6 +384,7 @@ export default function QueuePanel() {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
+34 -10
View File
@@ -1,6 +1,11 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
function fmt(s: number): string {
if (!s || isNaN(s)) return '0:00';
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
const BAR_COUNT = 500;
function hashStr(str: string): number {
@@ -125,9 +130,12 @@ export default function WaveformSeek({ trackId }: Props) {
const bufferedRef = useRef(0);
const isDragging = useRef(false);
const [hoverPct, setHoverPct] = useState<number | null>(null);
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const seek = usePlayerStore(s => s.seek);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
progressRef.current = progress;
bufferedRef.current = buffered;
@@ -175,14 +183,30 @@ export default function WaveformSeek({ trackId }: Props) {
}, []);
return (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
/>
<div style={{ position: 'relative', width: '100%' }}>
{hoverPct !== null && duration > 0 && (
<span
className="player-volume-pct"
style={{ left: `${hoverPct * 100}%` }}
>
{fmt(hoverPct * duration)}
</span>
)}
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
seekRef.current(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseMove={e => {
if (!trackId) return;
const rect = e.currentTarget.getBoundingClientRect();
setHoverPct(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}}
onMouseLeave={() => setHoverPct(null)}
/>
</div>
);
}
+16
View File
@@ -377,6 +377,8 @@ const enTranslation = {
crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Not available while Gapless is active',
notWithCrossfade: 'Not available while Crossfade is active',
gapless: 'Gapless Playback',
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
experimental: 'Experimental',
@@ -448,6 +450,8 @@ const enTranslation = {
a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (110 s) to taste.',
q30: 'Does Psysonic show lyrics?',
a30: 'Yes. Click the microphone icon in the player bar to open the Lyrics tab in the queue panel. Psysonic fetches lyrics automatically from LRCLIB. If synced lyrics are available, the active line is highlighted and scrolls in real time as the song plays. If only plain text is available, the full text is shown statically.',
q33: 'Can I seek (scrub) within a FLAC file?',
a33: 'Seeking in FLAC files requires a SEEKTABLE metadata block to be embedded in the file. Files that were encoded without one cannot be seeked — clicking the waveform will have no effect. Most modern encoders (e.g. ffmpeg, flac CLI) include a SEEKTABLE by default. You can add one to existing files using: flac --replay-gain *.flac or metaflac --add-seekpoint=10s *.flac',
q31: 'Can I customize keyboard shortcuts?',
a31: 'Yes. Settings → Keyboard Shortcuts lets you remap in-app actions (Play/Pause, Next, Previous, Volume Up/Down, Fullscreen, and more) to any key. Settings → Global Shortcuts lets you assign system-wide shortcuts that trigger even when Psysonic is in the background.',
q32: 'Can I change the font?',
@@ -916,6 +920,8 @@ const deTranslation = {
crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
gapless: 'Nahtlose Wiedergabe',
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
experimental: 'Experimentell',
@@ -987,6 +993,8 @@ const deTranslation = {
a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (110 s) einstellbar.',
q30: 'Zeigt Psysonic Songtexte an?',
a30: 'Ja. Klick auf das Mikrofon-Icon in der Playerleiste öffnet den Lyrics-Tab im Queue-Panel. Psysonic lädt Texte automatisch von LRCLIB. Sind synchronisierte Lyrics verfügbar, wird die aktuelle Zeile hervorgehoben und scrollt in Echtzeit mit. Bei reinen Texten wird der Volltext statisch angezeigt.',
q33: 'Kann ich in FLAC-Dateien seekbar (spulen)?',
a33: 'Das Spulen in FLAC-Dateien erfordert einen SEEKTABLE-Metadatenblock in der Datei. Dateien, die ohne diesen Block kodiert wurden, können nicht geseekt werden — ein Klick auf die Wellenform hat keinen Effekt. Die meisten modernen Encoder (z. B. ffmpeg, flac CLI) fügen standardmäßig eine SEEKTABLE ein. Mit folgendem Befehl kann man sie nachträglich hinzufügen: flac --replay-gain *.flac oder metaflac --add-seekpoint=10s *.flac',
q31: 'Kann ich Tastenkürzel anpassen?',
a31: 'Ja. Einstellungen → Tastenkürzel erlaubt das Neuzuordnen von In-App-Aktionen (Play/Pause, Weiter, Zurück, Lautstärke, Vollbild u. v. m.). Einstellungen → Globale Shortcuts ermöglicht systemweite Kürzel, die auch im Hintergrund ausgelöst werden.',
q32: 'Kann ich die Schriftart ändern?',
@@ -1455,6 +1463,8 @@ const frTranslation = {
crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
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',
experimental: 'Expérimental',
@@ -1526,6 +1536,8 @@ const frTranslation = {
a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (110 s).',
q30: 'Psysonic affiche-t-il les paroles ?',
a30: 'Oui. Cliquez sur l\'icône microphone dans la barre du lecteur pour ouvrir l\'onglet Paroles. Psysonic récupère automatiquement les paroles depuis LRCLIB. Si des paroles synchronisées sont disponibles, la ligne active est mise en évidence et défile en temps réel. Sinon, le texte complet est affiché statiquement.',
q33: 'Puis-je faire une recherche (scrubbing) dans un fichier FLAC ?',
a33: 'La navigation dans les fichiers FLAC nécessite un bloc de métadonnées SEEKTABLE intégré dans le fichier. Les fichiers encodés sans ce bloc ne peuvent pas être recherchés — cliquer sur la forme d\'onde n\'aura aucun effet. La plupart des encodeurs modernes (p. ex. ffmpeg, flac CLI) incluent une SEEKTABLE par défaut. Vous pouvez en ajouter une aux fichiers existants avec : flac --replay-gain *.flac ou metaflac --add-seekpoint=10s *.flac',
q31: 'Puis-je personnaliser les raccourcis clavier ?',
a31: 'Oui. Paramètres → Raccourcis clavier permet de réattribuer les actions in-app (Lecture/Pause, Suivant, Précédent, Volume, Plein écran, etc.). Paramètres → Raccourcis globaux permet d\'assigner des raccourcis système actifs même quand l\'application est en arrière-plan.',
q32: 'Puis-je changer la police ?',
@@ -1994,6 +2006,8 @@ const nlTranslation = {
crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
gapless: 'Naadloos afspelen',
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
experimental: 'Experimenteel',
@@ -2065,6 +2079,8 @@ const nlTranslation = {
a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (110 s) instelbaar.',
q30: 'Toont Psysonic songteksten?',
a30: 'Ja. Klik op het microfoonpictogram in de spelerbalk om het tabblad Songteksten te openen. Psysonic haalt teksten automatisch op van LRCLIB. Als gesynchroniseerde teksten beschikbaar zijn, wordt de actieve regel gemarkeerd en scrolt mee in realtime. Anders wordt de volledige tekst statisch getoond.',
q33: 'Kan ik door een FLAC-bestand scrubben (zoeken)?',
a33: 'Zoeken in FLAC-bestanden vereist een SEEKTABLE-metadatablok in het bestand. Bestanden die zonder dit blok zijn gecodeerd, kunnen niet worden doorzocht — klikken op de golfvorm heeft geen effect. De meeste moderne encoders (bijv. ffmpeg, flac CLI) bevatten standaard een SEEKTABLE. U kunt er een toevoegen aan bestaande bestanden met: flac --replay-gain *.flac of metaflac --add-seekpoint=10s *.flac',
q31: 'Kan ik sneltoetsen aanpassen?',
a31: 'Ja. Instellingen → Sneltoetsen laat je in-app acties (Afspelen/Pauzeren, Volgende, Vorige, Volume, Volledig scherm, enz.) hertoewijzen. Instellingen → Globale sneltoetsen laat je systeembrede sneltoetsen instellen die werken ook als de app op de achtergrond staat.',
q32: 'Kan ik het lettertype wijzigen?',
+1
View File
@@ -46,6 +46,7 @@ export default function Help() {
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
{ q: t('help.q30'), a: t('help.a30') },
{ q: t('help.q33'), a: t('help.a33') },
],
},
{
+18 -92
View File
@@ -156,34 +156,6 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
);
}
// ─── Blurred background ───────────────────────────────────────────────────────
const NpBg = memo(function NpBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const nextId = useRef(1);
useEffect(() => {
if (!url) return;
const id = nextId.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 30);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 700);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
return (
<div className="np-bg-wrap">
{layers.map(l => (
<div key={l.id} className="np-bg-layer"
style={{ backgroundImage: `url(${l.url})`, opacity: l.visible ? 1 : 0 }}
/>
))}
<div className="np-bg-overlay" />
</div>
);
});
// ─── Album Tracklist ──────────────────────────────────────────────────────────
@@ -286,47 +258,12 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
const [ambilightColors, setAmbilightColors] = useState({
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
ml: '0,0,0', mr: '0,0,0',
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
});
useEffect(() => {
if (!resolvedCover) return;
const img = new Image();
img.onload = () => {
const S = 30;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, S, S);
const data = ctx.getImageData(0, 0, S, S).data;
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
const avg = (x0: number, y0: number, x1: number, y1: number) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = (y * S + x) * 4;
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
}
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
};
setAmbilightColors({
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
});
};
img.src = resolvedCover;
}, [resolvedCover]);
const similarArtists = artistInfo?.similarArtist ?? [];
return (
<div className="np-page">
<NpBg url={resolvedCover ?? ''} />
<div className="np-main">
{currentTrack ? (
@@ -375,23 +312,9 @@ export default function NowPlaying() {
{/* Center: cover */}
<div className="np-hero-cover-wrap">
<div style={{
position: 'absolute', inset: '-20px', zIndex: 0,
background: `
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
`,
filter: 'blur(28px)',
}} />
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
}
</div>
@@ -404,7 +327,7 @@ export default function NowPlaying() {
</div>
{/* ── About the Artist ── */}
{(artistInfo?.biography || artistInfo?.largeImageUrl) && (
{artistInfo?.biography && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
@@ -416,19 +339,22 @@ export default function NowPlaying() {
</div>
<div className="np-artist-bio-row">
{artistInfo.largeImageUrl && (
<img src={artistInfo.largeImageUrl} alt={currentTrack.artist} className="np-artist-thumb" />
)}
{artistInfo.biography && (
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
<img
src={artistInfo.largeImageUrl}
alt={currentTrack.artist}
className="np-artist-thumb"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-bio-wrap">
<div
className={`np-bio-text${bioExpanded ? ' expanded' : ''}`}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(artistInfo.biography) }}
/>
<button className="np-bio-toggle" onClick={() => setBioExpanded(v => !v)}>
{bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
</button>
</div>
</div>
</div>
)}
+13 -7
View File
@@ -296,19 +296,22 @@ export default function Settings() {
<div className="divider" />
{/* Crossfade */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.crossfade')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
<input type="checkbox" checked={auth.crossfadeEnabled} onChange={e => auth.setCrossfadeEnabled(e.target.checked)} id="crossfade-toggle" />
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.crossfadeEnabled && (
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
@@ -329,15 +332,18 @@ export default function Settings() {
<div className="divider" />
{/* Gapless */}
<div className="settings-toggle-row">
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
<input type="checkbox" checked={auth.gaplessEnabled} onChange={e => auth.setGaplessEnabled(e.target.checked)} id="gapless-toggle" />
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
<span className="toggle-track" />
</label>
</div>
+9 -1
View File
@@ -131,6 +131,11 @@ let togglePlayLock = false;
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
// Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download.
let gaplessPreloadingId: string | null = null;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -177,7 +182,8 @@ function handleAudioProgress(current_time: number, duration: number) {
const nextTrack = repeatMode === 'one'
? track
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const nextUrl = buildStreamUrl(nextTrack.id);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
@@ -233,6 +239,7 @@ function handleAudioEnded() {
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
gaplessPreloadingId = null; // allow preloading for the track after this one
isAudioPaused = false;
const store = usePlayerStore.getState();
@@ -438,6 +445,7 @@ export const usePlayerStore = create<PlayerState>()(
const gen = ++playGeneration;
isAudioPaused = false;
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const state = get();
+4 -32
View File
@@ -2801,6 +2801,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
var(--bg-main);
}
/* Main scrollable content */
@@ -3175,38 +3179,6 @@
flex-shrink: 0;
}
.np-bg-wrap {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
contain: paint;
}
.np-bg-layer {
position: absolute;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(70px) brightness(0.55) saturate(1.6);
transition: opacity 0.6s ease;
animation: np-ken-burns 40s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes np-ken-burns {
0% { transform: scale(1.00) translate(0%, 0%); }
25% { transform: scale(1.04) translate(-2%, -1.5%); }
50% { transform: scale(1.06) translate(1.5%, 2%); }
75% { transform: scale(1.03) translate(-1%, 1.5%); }
100% { transform: scale(1.05) translate(2%, -1%); }
}
.np-bg-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.38);
}
.np-hero-content {
position: relative;
+5 -9
View File
@@ -804,20 +804,16 @@
.queue-current-tech {
position: absolute;
bottom: 0;
left: 0;
top: 0;
right: 0;
font-size: 9px;
font-family: monospace;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.85);
padding: 3px 6px;
text-align: center;
background: var(--ctp-surface1);
color: var(--accent);
padding: 2px 6px;
border-radius: 0 0 0 var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-divider {