import { useState, useCallback, useRef, useEffect } from 'react'; import { getAudioContext } from '@/lib/audio/context'; import type { Track } from '@/types/track'; import { getTrackGain } from '@/lib/audio/track-utils'; export interface MultiTrackPlayerState { isPlaying: boolean; currentTime: number; duration: number; } export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const audioContextRef = useRef(null); const sourceNodesRef = useRef([]); const gainNodesRef = useRef([]); const panNodesRef = useRef([]); const masterGainNodeRef = useRef(null); const startTimeRef = useRef(0); const pausedAtRef = useRef(0); const animationFrameRef = useRef(null); // Calculate total duration from all tracks useEffect(() => { let maxDuration = 0; for (const track of tracks) { if (track.audioBuffer) { maxDuration = Math.max(maxDuration, track.audioBuffer.duration); } } setDuration(maxDuration); }, [tracks]); const updatePlaybackPosition = useCallback(() => { if (!audioContextRef.current) return; const elapsed = audioContextRef.current.currentTime - startTimeRef.current; const newTime = pausedAtRef.current + elapsed; if (newTime >= duration) { setIsPlaying(false); setCurrentTime(0); pausedAtRef.current = 0; if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } return; } setCurrentTime(newTime); animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); }, [duration]); const play = useCallback(() => { if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) return; const audioContext = getAudioContext(); audioContextRef.current = audioContext; // Stop any existing playback sourceNodesRef.current.forEach(node => { try { node.stop(); node.disconnect(); } catch (e) { // Ignore errors from already stopped nodes } }); gainNodesRef.current.forEach(node => node.disconnect()); panNodesRef.current.forEach(node => node.disconnect()); if (masterGainNodeRef.current) { masterGainNodeRef.current.disconnect(); } sourceNodesRef.current = []; gainNodesRef.current = []; panNodesRef.current = []; // Create master gain node const masterGain = audioContext.createGain(); masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime); masterGain.connect(audioContext.destination); masterGainNodeRef.current = masterGain; // Create audio graph for each track for (const track of tracks) { if (!track.audioBuffer) continue; const source = audioContext.createBufferSource(); source.buffer = track.audioBuffer; const gainNode = audioContext.createGain(); const panNode = audioContext.createStereoPanner(); // Set gain based on track volume and solo/mute state const trackGain = getTrackGain(track, tracks); gainNode.gain.setValueAtTime(trackGain, audioContext.currentTime); // Set pan panNode.pan.setValueAtTime(track.pan, audioContext.currentTime); // Connect: source -> gain -> pan -> master gain -> destination source.connect(gainNode); gainNode.connect(panNode); panNode.connect(masterGain); // Start playback from current position source.start(0, pausedAtRef.current); // Store references sourceNodesRef.current.push(source); gainNodesRef.current.push(gainNode); panNodesRef.current.push(panNode); // Handle ended event source.onended = () => { if (pausedAtRef.current + (audioContext.currentTime - startTimeRef.current) >= duration) { setIsPlaying(false); setCurrentTime(0); pausedAtRef.current = 0; } }; } startTimeRef.current = audioContext.currentTime; setIsPlaying(true); updatePlaybackPosition(); }, [tracks, duration, masterVolume, updatePlaybackPosition]); const pause = useCallback(() => { if (!audioContextRef.current || !isPlaying) return; // Stop all source nodes sourceNodesRef.current.forEach(node => { try { node.stop(); node.disconnect(); } catch (e) { // Ignore errors } }); // Update paused position const elapsed = audioContextRef.current.currentTime - startTimeRef.current; pausedAtRef.current = Math.min(pausedAtRef.current + elapsed, duration); setCurrentTime(pausedAtRef.current); setIsPlaying(false); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }, [isPlaying, duration]); const stop = useCallback(() => { pause(); pausedAtRef.current = 0; setCurrentTime(0); }, [pause]); const seek = useCallback((time: number) => { const wasPlaying = isPlaying; if (wasPlaying) { pause(); } const clampedTime = Math.max(0, Math.min(time, duration)); pausedAtRef.current = clampedTime; setCurrentTime(clampedTime); if (wasPlaying) { // Small delay to ensure state is updated setTimeout(() => play(), 10); } }, [isPlaying, duration, pause, play]); const togglePlayPause = useCallback(() => { if (isPlaying) { pause(); } else { play(); } }, [isPlaying, play, pause]); // Update gain/pan when tracks change useEffect(() => { if (!isPlaying || !audioContextRef.current) return; tracks.forEach((track, index) => { if (gainNodesRef.current[index]) { const trackGain = getTrackGain(track, tracks); gainNodesRef.current[index].gain.setValueAtTime( trackGain, audioContextRef.current!.currentTime ); } if (panNodesRef.current[index]) { panNodesRef.current[index].pan.setValueAtTime( track.pan, audioContextRef.current!.currentTime ); } }); }, [tracks, isPlaying]); // Update master volume when it changes useEffect(() => { if (!isPlaying || !audioContextRef.current || !masterGainNodeRef.current) return; masterGainNodeRef.current.gain.setValueAtTime( masterVolume, audioContextRef.current.currentTime ); }, [masterVolume, isPlaying]); // Cleanup on unmount useEffect(() => { return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } sourceNodesRef.current.forEach(node => { try { node.stop(); node.disconnect(); } catch (e) { // Ignore } }); gainNodesRef.current.forEach(node => node.disconnect()); panNodesRef.current.forEach(node => node.disconnect()); if (masterGainNodeRef.current) { masterGainNodeRef.current.disconnect(); } }; }, []); return { isPlaying, currentTime, duration, play, pause, stop, seek, togglePlayPause, }; }