Files
rehearshalhub/web/src/hooks/useWaveform.ts
Mistral Vibe 48a73246a1 fix(lint): resolve eslint errors and warnings
- 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>
2026-04-08 21:52:44 +02:00

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")}`;
}