mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
434ee0ecf1
### Added - Favorite/Star button in player bar (requested by @halfkey) - Bulk multi-select for album tracklist and playlist detail (add to playlist, remove from playlist) - Song Info modal via right-click context menu (metadata: format, bitrate, sample rate, bit depth, channels, file size, path, replay gain) - Recently Played section on Home page - "Show activity in Now Playing" opt-in toggle in Settings → Behavior ### Fixed - Queue cover art not updating on track change (useCachedUrl/CachedImage reset on cacheKey change) - FullscreenPlayer background flickering (FsBg preloads image before layer transition) - Playlist card delete confirmation visual (size expansion + pulse animation) - Gruvbox Light Soft: back button and badge invisible against light background ### Changed - buildStreamUrl: removed unused suffix parameter Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
|
import {
|
|
Play, Pause, SkipBack, SkipForward,
|
|
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
|
|
} from 'lucide-react';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { useLyricsStore } from '../store/lyricsStore';
|
|
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
|
import CachedImage, { useCachedUrl } from './CachedImage';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
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 }) {
|
|
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
|
|
url ? [{ url, id: 0, visible: true }] : []
|
|
);
|
|
const counterRef = useRef(1);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
setLayers(prev => [...prev, { url, id, visible: false }]);
|
|
requestAnimationFrame(() => {
|
|
if (cancelled) return;
|
|
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
|
|
setTimeout(() => {
|
|
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
|
|
}, 800);
|
|
});
|
|
};
|
|
img.src = url;
|
|
return () => { cancelled = true; };
|
|
}, [url]);
|
|
|
|
return (
|
|
<>
|
|
{layers.map(layer => (
|
|
<div
|
|
key={layer.id}
|
|
className="fs-bg"
|
|
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
|
|
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
|
|
const progress = usePlayerStore(s => s.progress);
|
|
const buffered = usePlayerStore(s => s.buffered);
|
|
const currentTime = usePlayerStore(s => s.currentTime);
|
|
const seek = usePlayerStore(s => s.seek);
|
|
|
|
const handleSeek = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
|
|
[seek]
|
|
);
|
|
|
|
const pct = progress * 100;
|
|
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">
|
|
<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"
|
|
/>
|
|
</div>
|
|
<span className="fs-time">{formatTime(duration)}</span>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
|
const FsPlayBtn = memo(function FsPlayBtn() {
|
|
const { t } = useTranslation();
|
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
|
const togglePlay = usePlayerStore(s => s.togglePlay);
|
|
return (
|
|
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
|
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
// ─── Main component ────────────────────────────────────────────────────────────
|
|
interface FullscreenPlayerProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|
const { t } = useTranslation();
|
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
|
const repeatMode = usePlayerStore(s => s.repeatMode);
|
|
const next = usePlayerStore(s => s.next);
|
|
const previous = usePlayerStore(s => s.previous);
|
|
const stop = usePlayerStore(s => s.stop);
|
|
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
|
|
|
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 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).
|
|
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
|
|
|
// Fetch artist image for background — fall back to cover art if unavailable
|
|
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
|
useEffect(() => {
|
|
setArtistBgUrl('');
|
|
const artistId = currentTrack?.artistId;
|
|
if (!artistId) return;
|
|
let cancelled = false;
|
|
getArtistInfo(artistId).then(info => {
|
|
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
|
|
}).catch(() => {});
|
|
return () => { cancelled = true; };
|
|
}, [currentTrack?.artistId]);
|
|
|
|
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
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" />
|
|
|
|
{/* Close */}
|
|
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
|
<ChevronDown size={28} />
|
|
</button>
|
|
|
|
|
|
{/* Center stage — everything vertically + horizontally centered */}
|
|
<div className="fs-stage">
|
|
|
|
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
|
|
|
<div className="fs-cover-wrap">
|
|
{coverUrl ? (
|
|
<CachedImage
|
|
src={coverUrl}
|
|
cacheKey={coverKey}
|
|
alt={`${currentTrack?.album} Cover`}
|
|
className="fs-cover"
|
|
/>
|
|
) : (
|
|
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></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>
|
|
|
|
<FsProgress duration={duration} />
|
|
|
|
<div className="fs-controls">
|
|
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
|
|
<Square size={14} fill="currentColor" />
|
|
</button>
|
|
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
|
<SkipBack size={20} />
|
|
</button>
|
|
<FsPlayBtn />
|
|
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
|
<SkipForward size={20} />
|
|
</button>
|
|
<button
|
|
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
|
onClick={toggleRepeat}
|
|
aria-label={t('player.repeat')}
|
|
>
|
|
{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>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|