- audioService: replace 'as any' with 'as unknown as AudioService' in resetInstance() to satisfy @typescript-eslint/no-explicit-any - SongPage: add isReady to spacebar useEffect deps so the handler always sees the current readiness state - useWaveform: add containerRef to deps (stable ref, safe to include); suppress exhaustive-deps for options.onReady with explanation — adding an un-memoized callback would cause initialization on every render Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
5.6 KiB
TypeScript
Executable File
160 lines
5.6 KiB
TypeScript
Executable File
import { useEffect, useRef, useState } from "react";
|
|
import { audioService } from "../services/audioService";
|
|
import { usePlayerStore } from "../stores/playerStore";
|
|
|
|
export interface UseWaveformOptions {
|
|
url: string | null;
|
|
peaksUrl: string | null;
|
|
onReady?: (duration: number) => void;
|
|
onTimeUpdate?: (currentTime: number) => void;
|
|
songId?: string | null;
|
|
bandId?: string | null;
|
|
}
|
|
|
|
export interface CommentMarker {
|
|
id: string;
|
|
time: number; // Time in seconds
|
|
onClick: () => void;
|
|
icon?: string; // URL for the account icon
|
|
}
|
|
|
|
export function useWaveform(
|
|
containerRef: React.RefObject<HTMLDivElement>,
|
|
options: UseWaveformOptions
|
|
) {
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const markersRef = useRef<CommentMarker[]>([]);
|
|
|
|
// Playback state comes directly from the store — no intermediate local state
|
|
// or RAF polling loop needed. The store is updated by WaveSurfer event handlers
|
|
// in AudioService, so these values are always in sync.
|
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
|
const currentTime = usePlayerStore(state => state.currentTime);
|
|
const duration = usePlayerStore(state => state.duration);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
|
|
|
|
const initializeAudio = async () => {
|
|
try {
|
|
await audioService.initialize(containerRef.current!, options.url!);
|
|
|
|
// Restore playback if this song was already playing when the page loaded.
|
|
// Read as a one-time snapshot — these values must NOT be reactive deps or
|
|
// the effect would re-run on every time update (re-initializing WaveSurfer).
|
|
const {
|
|
currentSongId,
|
|
currentBandId,
|
|
isPlaying: wasPlaying,
|
|
currentTime: savedTime,
|
|
} = usePlayerStore.getState();
|
|
|
|
if (
|
|
options.songId &&
|
|
options.bandId &&
|
|
currentSongId === options.songId &&
|
|
currentBandId === options.bandId &&
|
|
wasPlaying &&
|
|
audioService.isWaveformReady()
|
|
) {
|
|
try {
|
|
await audioService.play(options.songId, options.bandId);
|
|
if (savedTime > 0) audioService.seekTo(savedTime);
|
|
} catch (err) {
|
|
console.warn('Auto-play prevented during initialization:', err);
|
|
}
|
|
}
|
|
|
|
setIsReady(true);
|
|
options.onReady?.(audioService.getDuration());
|
|
} catch (err) {
|
|
console.error('useWaveform: initialization failed', err);
|
|
setIsReady(false);
|
|
setError(err instanceof Error ? err.message : 'Failed to initialize audio');
|
|
}
|
|
};
|
|
|
|
initializeAudio();
|
|
// containerRef is a stable ref object — safe to include.
|
|
// options.onReady is intentionally omitted: it's a callback that callers
|
|
// may not memoize, and re-running initialization on every render would be
|
|
// worse than stale-closing over it for the brief window after mount.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [options.url, options.songId, options.bandId, containerRef]);
|
|
|
|
const play = () => {
|
|
audioService.play(options.songId ?? null, options.bandId ?? null)
|
|
.catch(err => console.error('[useWaveform] play failed:', err));
|
|
};
|
|
|
|
const pause = () => {
|
|
audioService.pause();
|
|
};
|
|
|
|
const seekTo = (time: number) => {
|
|
audioService.seekTo(time);
|
|
};
|
|
|
|
const addMarker = (marker: CommentMarker) => {
|
|
if (!isReady) return;
|
|
try {
|
|
const markerElement = document.createElement("div");
|
|
markerElement.style.position = "absolute";
|
|
markerElement.style.width = "24px";
|
|
markerElement.style.height = "24px";
|
|
markerElement.style.borderRadius = "50%";
|
|
markerElement.style.backgroundColor = "var(--accent)";
|
|
markerElement.style.cursor = "pointer";
|
|
markerElement.style.zIndex = "9999";
|
|
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
|
|
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
|
markerElement.style.top = "50%";
|
|
markerElement.style.border = "2px solid white";
|
|
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
|
|
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
|
markerElement.onclick = marker.onClick;
|
|
|
|
if (marker.icon) {
|
|
const iconElement = document.createElement("img");
|
|
iconElement.src = marker.icon;
|
|
iconElement.style.width = "100%";
|
|
iconElement.style.height = "100%";
|
|
iconElement.style.borderRadius = "50%";
|
|
iconElement.style.objectFit = "cover";
|
|
markerElement.appendChild(iconElement);
|
|
}
|
|
|
|
const waveformContainer = containerRef.current;
|
|
if (waveformContainer) {
|
|
waveformContainer.style.position = "relative";
|
|
waveformContainer.appendChild(markerElement);
|
|
}
|
|
|
|
markersRef.current.push(marker);
|
|
} catch (err) {
|
|
console.error('useWaveform.addMarker failed:', err);
|
|
}
|
|
};
|
|
|
|
const clearMarkers = () => {
|
|
const waveformContainer = containerRef.current;
|
|
if (waveformContainer) {
|
|
const markerElements = waveformContainer.querySelectorAll("div[title^='Comment at']");
|
|
markerElements.forEach((element) => {
|
|
waveformContainer.removeChild(element);
|
|
});
|
|
}
|
|
markersRef.current = [];
|
|
};
|
|
|
|
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers, error };
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|