'use client'; import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, '0')}`; } // Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari // Must call .play() on WaveSurfer's actual media element during a user gesture let sharedWaveSurfer: WaveSurfer | null = null; let audioUnlocked = false; export function unlockAudioContext() { if (audioUnlocked) return; audioUnlocked = true; // Unlock WaveSurfer's internal audio element // Skip if already playing — the context is already unlocked and the // play/pause/reset dance would destroy the active playback. if (sharedWaveSurfer && !sharedWaveSurfer.isPlaying()) { const media = sharedWaveSurfer.getMediaElement(); if (media) { media.muted = true; media .play() .then(() => { media.pause(); media.muted = false; media.currentTime = 0; }) .catch(() => {}); } } // Also unlock a standalone AudioContext as fallback try { const ctx = new ( window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext )(); const buffer = ctx.createBuffer(1, 1, 22050); const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); source.start(0); } catch { // Silently fail } } interface LandingAudioPlayerProps { audioUrl: string; title: string; playing: boolean; muted: boolean; onFinish: () => void; onClose: () => void; } export function LandingAudioPlayer({ audioUrl, title, playing, muted, onFinish, onClose, }: LandingAudioPlayerProps) { const waveformRef = useRef(null); const wavesurferRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(0.75); const [isLooping, setIsLooping] = useState(false); const [isReady, setIsReady] = useState(false); const onFinishRef = useRef(onFinish); onFinishRef.current = onFinish; const playingRef = useRef(playing); playingRef.current = playing; const mutedRef = useRef(muted); mutedRef.current = muted; // Initialize WaveSurfer useEffect(() => { const initWaveSurfer = () => { const container = waveformRef.current; if (!container) { setTimeout(initWaveSurfer, 50); return; } const rect = container.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { setTimeout(initWaveSurfer, 50); return; } // Clean up existing instance if (wavesurferRef.current) { wavesurferRef.current.destroy(); wavesurferRef.current = null; } const root = document.documentElement; const getCSSVar = (varName: string) => { const value = getComputedStyle(root).getPropertyValue(varName).trim(); return value ? `hsl(${value})` : ''; }; const ws = WaveSurfer.create({ container, waveColor: getCSSVar('--muted'), progressColor: getCSSVar('--accent'), cursorColor: getCSSVar('--accent'), barWidth: 2, barRadius: 2, height: 80, normalize: true, interact: true, mediaControls: false, }); ws.on('ready', () => { setDuration(ws.getDuration()); ws.setVolume(mutedRef.current ? 0 : volume); setIsReady(true); }); ws.on('play', () => { console.log('[Player] play event'); setIsPlaying(true); }); ws.on('pause', () => { console.log('[Player] pause event'); setIsPlaying(false); }); ws.on('timeupdate', (time: number) => { setCurrentTime(Math.min(time, ws.getDuration())); }); let didFinish = false; ws.on('finish', () => { if (didFinish) return; didFinish = true; console.log( '[Player] finish event, currentTime:', ws.getCurrentTime(), 'duration:', ws.getDuration(), ); setIsPlaying(false); onFinishRef.current(); }); ws.load(audioUrl); wavesurferRef.current = ws; sharedWaveSurfer = ws; }; setIsReady(false); setCurrentTime(0); setDuration(0); requestAnimationFrame(() => { requestAnimationFrame(() => { setTimeout(initWaveSurfer, 10); }); }); return () => { if (wavesurferRef.current) { wavesurferRef.current.destroy(); wavesurferRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [audioUrl]); // Respond to external play/stop signals useEffect(() => { const ws = wavesurferRef.current; console.log('[Player] effect', { playing, isReady, hasWs: !!ws }); if (!ws || !isReady) return; if (playing) { // Resume the AudioContext first (required for iOS Safari after unlock) const backend = ws.getMediaElement(); if (backend && 'context' in backend) { const ctx = (backend as unknown as { context: AudioContext }).context; if (ctx?.state === 'suspended') ctx.resume(); } ws.play() .then(() => { console.log('[Player] play succeeded'); }) .catch((e: Error) => { if (e.name === 'NotAllowedError') { console.warn('[Player] Autoplay blocked by browser — waiting for user gesture'); } else { console.error('[Player] play failed', e); } }); } else { ws.pause(); } }, [playing, isReady]); // Sync volume and muted state useEffect(() => { if (wavesurferRef.current) { wavesurferRef.current.setVolume(muted ? 0 : volume); } }, [volume, muted]); const handlePlayPause = useCallback(() => { if (!wavesurferRef.current) return; wavesurferRef.current.playPause(); }, []); return (
{/* Waveform — full width row on mobile, inline on desktop */}
{/* Controls row */}
{/* Play/Pause */} {/* Time */}
{formatDuration(currentTime)} / {formatDuration(duration)}
{/* Title */} {title && (
{title}
)} {/* Loop */} {/* Volume */}
setVolume(Number(e.target.value) / 100)} className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground" />
); }