2025-11-17 21:03:39 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 07:20:29 +01:00
|
|
|
export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
2025-11-17 21:03:39 +01:00
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
|
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
|
|
|
|
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
|
|
|
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
|
|
|
|
const gainNodesRef = useRef<GainNode[]>([]);
|
|
|
|
|
const panNodesRef = useRef<StereoPannerNode[]>([]);
|
2025-11-18 07:20:29 +01:00
|
|
|
const masterGainNodeRef = useRef<GainNode | null>(null);
|
2025-11-17 21:03:39 +01:00
|
|
|
const startTimeRef = useRef<number>(0);
|
|
|
|
|
const pausedAtRef = useRef<number>(0);
|
|
|
|
|
const animationFrameRef = useRef<number | null>(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(() => {
|
2025-11-18 07:16:07 +01:00
|
|
|
if (!audioContextRef.current) return;
|
2025-11-17 21:03:39 +01:00
|
|
|
|
|
|
|
|
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
|
|
|
|
const newTime = pausedAtRef.current + elapsed;
|
|
|
|
|
|
|
|
|
|
if (newTime >= duration) {
|
|
|
|
|
setIsPlaying(false);
|
|
|
|
|
setCurrentTime(0);
|
|
|
|
|
pausedAtRef.current = 0;
|
2025-11-18 07:16:07 +01:00
|
|
|
if (animationFrameRef.current) {
|
|
|
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
|
|
|
animationFrameRef.current = null;
|
|
|
|
|
}
|
2025-11-17 21:03:39 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setCurrentTime(newTime);
|
|
|
|
|
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
2025-11-18 07:16:07 +01:00
|
|
|
}, [duration]);
|
2025-11-17 21:03:39 +01:00
|
|
|
|
|
|
|
|
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());
|
2025-11-18 07:20:29 +01:00
|
|
|
if (masterGainNodeRef.current) {
|
|
|
|
|
masterGainNodeRef.current.disconnect();
|
|
|
|
|
}
|
2025-11-17 21:03:39 +01:00
|
|
|
|
|
|
|
|
sourceNodesRef.current = [];
|
|
|
|
|
gainNodesRef.current = [];
|
|
|
|
|
panNodesRef.current = [];
|
|
|
|
|
|
2025-11-18 07:20:29 +01:00
|
|
|
// Create master gain node
|
|
|
|
|
const masterGain = audioContext.createGain();
|
|
|
|
|
masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime);
|
|
|
|
|
masterGain.connect(audioContext.destination);
|
|
|
|
|
masterGainNodeRef.current = masterGain;
|
|
|
|
|
|
2025-11-17 21:03:39 +01:00
|
|
|
// 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);
|
|
|
|
|
|
2025-11-18 07:20:29 +01:00
|
|
|
// Connect: source -> gain -> pan -> master gain -> destination
|
2025-11-17 21:03:39 +01:00
|
|
|
source.connect(gainNode);
|
|
|
|
|
gainNode.connect(panNode);
|
2025-11-18 07:20:29 +01:00
|
|
|
panNode.connect(masterGain);
|
2025-11-17 21:03:39 +01:00
|
|
|
|
|
|
|
|
// 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();
|
2025-11-18 07:20:29 +01:00
|
|
|
}, [tracks, duration, masterVolume, updatePlaybackPosition]);
|
2025-11-17 21:03:39 +01:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2025-11-18 07:20:29 +01:00
|
|
|
// Update master volume when it changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isPlaying || !audioContextRef.current || !masterGainNodeRef.current) return;
|
|
|
|
|
|
|
|
|
|
masterGainNodeRef.current.gain.setValueAtTime(
|
|
|
|
|
masterVolume,
|
|
|
|
|
audioContextRef.current.currentTime
|
|
|
|
|
);
|
|
|
|
|
}, [masterVolume, isPlaying]);
|
|
|
|
|
|
2025-11-17 21:03:39 +01:00
|
|
|
// 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());
|
2025-11-18 07:20:29 +01:00
|
|
|
if (masterGainNodeRef.current) {
|
|
|
|
|
masterGainNodeRef.current.disconnect();
|
|
|
|
|
}
|
2025-11-17 21:03:39 +01:00
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isPlaying,
|
|
|
|
|
currentTime,
|
|
|
|
|
duration,
|
|
|
|
|
play,
|
|
|
|
|
pause,
|
|
|
|
|
stop,
|
|
|
|
|
seek,
|
|
|
|
|
togglePlayPause,
|
|
|
|
|
};
|
|
|
|
|
}
|