230 lines
7.4 KiB
TypeScript
Executable File
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")}`;
|
|
} |