- Connect MiniPlayer play/pause buttons to audioService - Improve audio context management with fallback creation - Fix state synchronization with interval-based readiness checks - Add error handling and user feedback for playback issues - Enhance mobile browser support with better audio context handling Fixes playback issues in SongView where controls were not working and state synchronization between UI and player was unreliable.
226 lines
7.1 KiB
TypeScript
Executable File
226 lines
7.1 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,
|
|
setCurrentSong
|
|
} = usePlayerStore(state => ({
|
|
isPlaying: state.isPlaying,
|
|
currentTime: state.currentTime,
|
|
currentSongId: state.currentSongId,
|
|
currentBandId: state.currentBandId,
|
|
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 same song that was playing globally, restore play state
|
|
if (options.songId && options.bandId &&
|
|
currentSongId === options.songId &&
|
|
globalBandId === 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();
|
|
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();
|
|
} 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")}`;
|
|
} |