mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: Waveform seekbar, MilkDrop visualizer, EQ bars, in-app browser, and UX improvements (v1.3.0)
- PlayerBar redesigned: canvas waveform seekbar (500 bars, blue→mauve gradient + glow) replaces thin slider; new flex layout; queue toggle moved to content header (consistent with sidebar pattern) - MilkDrop visualizer in Ambient Stage via Butterchurn; hidden audio analysis, preset shuffle, smooth blend transitions - Tracklist: animated EQ bars for playing track, play icon when paused, align-items: center fix - Artist pages: Last.fm + Wikipedia open in native Tauri WebviewWindow (in-app browser) - Hero/Discover deduplication: single fetch of 20 random albums split between sections - Update checker: runs every 10 minutes during runtime; version shown without v-prefix - Settings version: now read from package.json instead of hardcoded - Help page: new Random Mix section, updated Playback + Library sections, improved accordion styling - Bump version to 1.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward,
|
||||
ChevronDown, Repeat, Repeat1, Square, Music
|
||||
ChevronDown, Repeat, Repeat1, Square, Music, AudioWaveform, Shuffle
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VisualizerCanvas from './VisualizerCanvas';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -147,6 +148,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
|
||||
const [vizActive, setVizActive] = useState(false);
|
||||
const [nextPresetTrigger, setNextPresetTrigger] = useState(0);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
@@ -177,20 +182,59 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
|
||||
{/* Layer 1 — blurred artist image (falls back to cover art) */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Layer 2 — drifting color orbs */}
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
{/* Layer 1 — blurred artist image OR visualizer */}
|
||||
{vizActive && currentTrack ? (
|
||||
<VisualizerCanvas
|
||||
trackId={currentTrack.id}
|
||||
nextPresetTrigger={nextPresetTrigger}
|
||||
onPresetName={setPresetName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Visualizer toggle + preset controls */}
|
||||
<div style={{ position: 'absolute', top: '1.25rem', right: '4rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{vizActive && (
|
||||
<>
|
||||
{presetName && (
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{presetName}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="fs-btn fs-btn-sm"
|
||||
onClick={() => setNextPresetTrigger(n => n + 1)}
|
||||
aria-label={t('player.nextPreset')}
|
||||
data-tooltip={t('player.nextPreset')}
|
||||
style={{ color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${vizActive ? 'active' : ''}`}
|
||||
onClick={() => setVizActive(v => !v)}
|
||||
aria-label={t('player.visualizer')}
|
||||
data-tooltip={t('player.visualizer')}
|
||||
style={{ color: vizActive ? 'white' : 'rgba(255,255,255,0.5)' }}
|
||||
>
|
||||
<AudioWaveform size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
|
||||
|
||||
@@ -42,7 +42,11 @@ function HeroBg({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
interface HeroProps {
|
||||
albums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -50,8 +54,9 @@ export default function Hero() {
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsProp?.length) { setAlbums(albumsProp); return; }
|
||||
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {});
|
||||
}, []);
|
||||
}, [albumsProp]);
|
||||
|
||||
// Start / restart auto-advance timer
|
||||
const startTimer = useCallback((len: number) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, List, Square, Repeat, Repeat1, Maximize2
|
||||
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
|
||||
Square, Repeat, Repeat1, Maximize2
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -18,32 +20,27 @@ function formatTime(seconds: number): string {
|
||||
export default function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
|
||||
const {
|
||||
currentTrack, isPlaying, currentTime, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
} = usePlayerStore();
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
|
||||
|
||||
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
seek(parseFloat(e.target.value));
|
||||
}, [seek]);
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
}, [setVolume]);
|
||||
|
||||
const pct = progress * 100;
|
||||
const buf = Math.max(pct, buffered * 100);
|
||||
const progressStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`,
|
||||
};
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="player-track-info">
|
||||
<div
|
||||
@@ -89,88 +86,69 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls + Progress */}
|
||||
<div className="player-controls">
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={16} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
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')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : 'inherit' }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-progress">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-progress-bar">
|
||||
<input
|
||||
type="range"
|
||||
id="player-seek"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={progressStyle}
|
||||
aria-label={t('player.progress')}
|
||||
/>
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume + Connection */}
|
||||
<div className="player-right">
|
||||
<div className="volume-control" aria-label={t('player.volume')}>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={toggleQueue}
|
||||
aria-label={t('player.toggleQueue')}
|
||||
data-tooltip={t('player.toggleQueue')}
|
||||
style={{ marginLeft: 'var(--space-3)', color: isQueueVisible ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||
{/* Transport Controls */}
|
||||
<div className="player-buttons">
|
||||
<button className="player-btn player-btn-sm" onClick={stop} aria-label={t('player.stop')} data-tooltip={t('player.stop')}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-primary"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
<List size={18} />
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
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')}`}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waveform Seekbar */}
|
||||
<div className="player-waveform-section">
|
||||
<span className="player-time">{formatTime(currentTime)}</span>
|
||||
<div className="player-waveform-wrap">
|
||||
<WaveformSeek trackId={currentTrack?.id} />
|
||||
</div>
|
||||
<span className="player-time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
||||
aria-label={t('player.volume')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
id="player-volume"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={handleVolume}
|
||||
style={volumeStyle}
|
||||
aria-label={t('player.volume')}
|
||||
className="player-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,10 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
|
||||
);
|
||||
}
|
||||
|
||||
// Module-level fallback for fromIdx — survives the dragend-before-drop race on
|
||||
// macOS WKWebView AND the dataTransfer.getData('') bug on Windows WebView2.
|
||||
let _dragFromIdx: number | null = null;
|
||||
|
||||
export default function QueuePanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -154,10 +158,9 @@ export default function QueuePanel() {
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
isDraggingInternalRef.current = true;
|
||||
draggedIdxRef.current = index;
|
||||
_dragFromIdx = index;
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Store index in dataTransfer too — on macOS WKWebView dragend fires before
|
||||
// drop, so the ref will already be null; dataTransfer survives that race.
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
|
||||
};
|
||||
|
||||
@@ -179,6 +182,9 @@ export default function QueuePanel() {
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
// _dragFromIdx intentionally NOT cleared here — drop fires after dragend on
|
||||
// macOS WKWebView, so we need the value to survive into onDropQueue.
|
||||
// It is cleared in onDropQueue after use instead.
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
@@ -197,9 +203,11 @@ export default function QueuePanel() {
|
||||
if (raw) parsedData = JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (parsedData?.type === 'queue_reorder') {
|
||||
// fromIdx: always reliable from dataTransfer (set during dragstart)
|
||||
const fromIdx: number = parsedData.index;
|
||||
if (parsedData?.type === 'queue_reorder' || _dragFromIdx !== null) {
|
||||
// fromIdx: prefer dataTransfer value; fall back to module-level var for
|
||||
// Windows WebView2 where getData() can return '' in the drop handler.
|
||||
const fromIdx: number = parsedData?.index ?? _dragFromIdx!;
|
||||
_dragFromIdx = null;
|
||||
|
||||
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
|
||||
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
|
||||
@@ -220,6 +228,7 @@ export default function QueuePanel() {
|
||||
}
|
||||
|
||||
// External drop (song / album dragged from elsewhere in the app)
|
||||
_dragFromIdx = null;
|
||||
if (!parsedData) return;
|
||||
if (parsedData.type === 'song') {
|
||||
enqueue([parsedData.track]);
|
||||
|
||||
@@ -72,20 +72,25 @@ export default function Sidebar({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, appVersion)) {
|
||||
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
|
||||
setLatestVersion(tag.replace(/^v/i, ''));
|
||||
}
|
||||
} catch {
|
||||
// network unavailable — silently skip
|
||||
}
|
||||
}, 1500);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
};
|
||||
|
||||
const initial = setTimeout(check, 1500);
|
||||
const interval = setInterval(check, 10 * 60 * 1000); // every 10 minutes
|
||||
|
||||
return () => { cancelled = true; clearTimeout(initial); clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import butterchurn from 'butterchurn';
|
||||
import butterchurnPresets from 'butterchurn-presets';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
interface Props {
|
||||
trackId: string;
|
||||
nextPresetTrigger: number;
|
||||
onPresetName: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function VisualizerCanvas({ trackId, nextPresetTrigger, onPresetName }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const vizRef = useRef<ReturnType<typeof butterchurn.createVisualizer> | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const presetNamesRef = useRef<string[]>([]);
|
||||
const presetMapRef = useRef<Record<string, unknown>>({});
|
||||
const presetIdxRef = useRef(0);
|
||||
|
||||
// ── Init audio analysis + butterchurn ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Hidden audio element — routed through Web Audio for analysis only.
|
||||
// NOT connected to AudioDestinationNode → completely silent.
|
||||
// The Rust/rodio engine plays the actual audio.
|
||||
const streamUrl = buildStreamUrl(trackId);
|
||||
const audio = new Audio(streamUrl);
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audioRef.current = audio;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
// Intentionally no: source.connect(ctx.destination)
|
||||
|
||||
// Size canvas to fill its container
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || window.innerWidth;
|
||||
const h = rect.height || window.innerHeight;
|
||||
canvas.width = w * (window.devicePixelRatio || 1);
|
||||
canvas.height = h * (window.devicePixelRatio || 1);
|
||||
|
||||
const visualizer = butterchurn.createVisualizer(ctx, canvas, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
});
|
||||
vizRef.current = visualizer;
|
||||
visualizer.connectAudio(source);
|
||||
|
||||
// Presets
|
||||
const presets = butterchurnPresets.getPresets();
|
||||
const names = Object.keys(presets);
|
||||
presetNamesRef.current = names;
|
||||
presetMapRef.current = presets;
|
||||
const startIdx = Math.floor(Math.random() * names.length);
|
||||
presetIdxRef.current = startIdx;
|
||||
visualizer.loadPreset(presets[names[startIdx]], 2.0);
|
||||
onPresetName(names[startIdx]);
|
||||
|
||||
// Sync position + play state with main player
|
||||
const { currentTime, isPlaying } = usePlayerStore.getState();
|
||||
audio.currentTime = currentTime;
|
||||
if (isPlaying) audio.play().catch(() => {});
|
||||
|
||||
// Render loop
|
||||
const render = () => {
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
visualizer.render();
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(render);
|
||||
|
||||
// Keep canvas sized to window
|
||||
const onResize = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
visualizer.setRendererSize(canvas.width, canvas.height);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Sync play/pause with main player
|
||||
const unsubscribe = usePlayerStore.subscribe(state => {
|
||||
const a = audioRef.current;
|
||||
const c = audioCtxRef.current;
|
||||
if (!a || !c) return;
|
||||
if (state.isPlaying) {
|
||||
c.resume();
|
||||
a.play().catch(() => {});
|
||||
} else {
|
||||
a.pause();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', onResize);
|
||||
unsubscribe();
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
ctx.close();
|
||||
vizRef.current = null;
|
||||
};
|
||||
}, [trackId]); // re-init on track change
|
||||
|
||||
// ── Next preset ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!nextPresetTrigger) return;
|
||||
const viz = vizRef.current;
|
||||
const names = presetNamesRef.current;
|
||||
if (!viz || names.length === 0) return;
|
||||
const next = (presetIdxRef.current + 1) % names.length;
|
||||
presetIdxRef.current = next;
|
||||
viz.loadPreset(presetMapRef.current[names[next]], 2.0);
|
||||
onPresetName(names[next]);
|
||||
}, [nextPresetTrigger]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
|
||||
function hashStr(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h ^ str.charCodeAt(i)) >>> 0;
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeHeights(trackId: string): Float32Array {
|
||||
let s = hashStr(trackId);
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
h[i] = s / 0xffffffff;
|
||||
}
|
||||
// Smooth for an organic look
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
for (let i = 1; i < BAR_COUNT - 1; i++) {
|
||||
h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25;
|
||||
}
|
||||
}
|
||||
// Normalize to [0.12, 1.0]
|
||||
let max = 0;
|
||||
for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i];
|
||||
if (max > 0) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) h[i] = 0.12 + (h[i] / max) * 0.88;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
progress: number,
|
||||
buffered: number,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width || canvas.clientWidth;
|
||||
const h = rect.height || canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const pw = Math.round(w * dpr);
|
||||
const ph = Math.round(h * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
|
||||
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps.
|
||||
const x1Of = (i: number) => (i / BAR_COUNT) * w;
|
||||
const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w;
|
||||
|
||||
// Pass 1 — unplayed (dim)
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.fillStyle = colorUnplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 2 — buffered (slightly brighter)
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = colorBuffered;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 3 — played (gradient + glow)
|
||||
if (progress > 0) {
|
||||
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
|
||||
grad.addColorStop(0, colorBlue);
|
||||
grad.addColorStop(1, colorMauve);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.shadowColor = colorMauve;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const barH = Math.max(1, heights[i] * h);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trackId: string | undefined;
|
||||
}
|
||||
|
||||
export default function WaveformSeek({ trackId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const heightsRef = useRef<Float32Array | null>(null);
|
||||
const progressRef = useRef(0);
|
||||
const bufferedRef = useRef(0);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const buffered = usePlayerStore(s => s.buffered);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
|
||||
progressRef.current = progress;
|
||||
bufferedRef.current = buffered;
|
||||
|
||||
useEffect(() => {
|
||||
heightsRef.current = trackId ? makeHeights(trackId) : null;
|
||||
}, [trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawWaveform(canvasRef.current, heightsRef.current, progress, buffered);
|
||||
}
|
||||
}, [progress, buffered, trackId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
drawWaveform(canvas, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const seekFromX = (clientX: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !trackId) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
seek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const up = () => { isDragging.current = false; };
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => window.removeEventListener('mouseup', up);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
|
||||
onMouseDown={e => { isDragging.current = true; seekFromX(e.clientX); }}
|
||||
onMouseMove={e => { if (isDragging.current) seekFromX(e.clientX); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user