Files
rehearshalhub/web/src/hooks/useWaveform.ts

230 lines
7.4 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 [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const markersRef = useRef<CommentMarker[]>([]);
// Global player state - use shallow comparison to reduce re-renders
const {
isPlaying: globalIsPlaying,
currentTime: globalCurrentTime,
currentSongId,
currentBandId: globalBandId,
currentPlayingSongId,
currentPlayingBandId,
setCurrentSong
} = usePlayerStore(state => ({
isPlaying: state.isPlaying,
currentTime: state.currentTime,
currentSongId: state.currentSongId,
currentBandId: state.currentBandId,
currentPlayingSongId: state.currentPlayingSongId,
currentPlayingBandId: state.currentPlayingBandId,
setCurrentSong: state.setCurrentSong
}));
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!);
// Set up local state synchronization with requestAnimationFrame for smoother updates
let animationFrameId: number | null = null;
let lastUpdateTime = 0;
const updateInterval = 1000 / 15; // ~15fps for state updates
const handleStateUpdate = () => {
const now = Date.now();
if (now - lastUpdateTime >= updateInterval) {
const state = usePlayerStore.getState();
setIsPlaying(state.isPlaying);
setCurrentTime(state.currentTime);
setDuration(state.duration);
lastUpdateTime = now;
}
animationFrameId = requestAnimationFrame(handleStateUpdate);
};
// Start the animation frame loop
animationFrameId = requestAnimationFrame(handleStateUpdate);
const unsubscribe = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
// Update global song context
if (options.songId && options.bandId) {
setCurrentSong(options.songId, options.bandId);
}
// If this is the currently playing song, restore play state
if (options.songId && options.bandId &&
currentPlayingSongId === options.songId &&
currentPlayingBandId === options.bandId &&
globalIsPlaying) {
// Wait for the waveform to be ready and audio context to be available
const checkReady = setInterval(() => {
if (audioService.getDuration() > 0) {
clearInterval(checkReady);
audioService.play(options.songId, options.bandId);
if (globalCurrentTime > 0) {
audioService.seekTo(globalCurrentTime);
}
}
}, 50);
}
setIsReady(true);
options.onReady?.(audioService.getDuration());
return () => {
unsubscribe();
// Note: We don't cleanup the audio service here to maintain persistence
// audioService.cleanup();
};
} catch (error) {
console.error('useWaveform: initialization failed', error);
setIsReady(false);
setError(error instanceof Error ? error.message : 'Failed to initialize audio');
return () => {};
}
};
initializeAudio();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
const play = () => {
try {
audioService.play(options.songId || null, options.bandId || null);
} catch (error) {
console.error('useWaveform.play failed:', error);
}
};
const pause = () => {
try {
audioService.pause();
} catch (error) {
console.error('useWaveform.pause failed:', error);
}
};
const seekTo = (time: number) => {
try {
if (isReady && isFinite(time)) {
audioService.seekTo(time);
}
} catch (error) {
console.error('useWaveform.seekTo failed:', error);
}
};
const addMarker = (marker: CommentMarker) => {
if (isReady) {
try {
// This would need proper implementation with the actual wavesurfer instance
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 (error) {
console.error('useWaveform.addMarker failed:', error);
}
}
};
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")}`;
}