import { useWavesurfer } from '@wavesurfer/react'; import formatDuration from 'format-duration'; import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { CustomPlayerbarSlider } from './playerbar-slider'; import styles from './playerbar-waveform.module.css'; import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { BarAlign, usePlayerbarSlider, usePlayerSong, usePlayerTimestamp } from '/@/renderer/store'; import { useAppThemeColors, useColorScheme } from '/@/renderer/themes/use-app-theme'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Text } from '/@/shared/components/text/text'; export const PlayerbarWaveform = () => { const currentSong = usePlayerSong(); const playerbarSlider = usePlayerbarSlider(); const currentTime = usePlayerTimestamp(); const containerRef = useRef(null); const { mediaSeekToTimestamp } = usePlayer(); const [isLoading, setIsLoading] = useState(true); const [isDragging, setIsDragging] = useState(false); const [tooltipPosition, setTooltipPosition] = useState(null); const [tooltipValue, setTooltipValue] = useState(0); const seekTimeoutRef = useRef(null); const lastSeekValueRef = useRef(null); const containerPositionRef = useRef(null); const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0; const streamUrl = useSongUrl(currentSong, true, { bitrate: 64, enabled: true, format: 'mp3' }); const { color } = useAppThemeColors(); const primaryColor = (color['--theme-colors-primary'] as string) || 'rgb(53, 116, 252)'; const colorScheme = useColorScheme(); const waveColor = useMemo(() => { return colorScheme === 'dark' ? 'rgba(96, 96, 96, 1)' : 'rgba(96, 96, 96, 1)'; }, [colorScheme]); const cursorColor = useMemo(() => { return colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'; }, [colorScheme]); const { wavesurfer } = useWavesurfer({ barAlign: playerbarSlider?.barAlign === BarAlign.CENTER ? undefined : playerbarSlider?.barAlign, barGap: playerbarSlider?.barGap, barRadius: playerbarSlider?.barRadius, barWidth: playerbarSlider?.barWidth, container: containerRef, cursorColor, cursorWidth: 2, fillParent: true, height: 18, interact: false, normalize: false, progressColor: primaryColor, url: streamUrl || undefined, waveColor, }); // Reset loading state when stream URL changes and ensure media is muted useEffect(() => { setIsLoading(true); if (wavesurfer) { wavesurfer.setVolume(0); const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.muted = true; mediaElement.volume = 0; } } }, [streamUrl, wavesurfer]); // Handle waveform ready state useEffect(() => { if (!wavesurfer) return; const handleReady = () => { setIsLoading(false); const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.muted = true; mediaElement.volume = 0; } }; wavesurfer.on('ready', handleReady); // Check if already loaded if (wavesurfer.getDuration() > 0) { setIsLoading(false); const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.muted = true; mediaElement.volume = 0; } } return () => { wavesurfer.un('ready', handleReady); }; }, [wavesurfer]); useEffect(() => { if (!wavesurfer) return; // Ensure waveform never plays - it's just for visualization wavesurfer.setVolume(0); const muteMediaElement = () => { const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.muted = true; mediaElement.volume = 0; } }; muteMediaElement(); const preventPlay = () => { wavesurfer.pause(); muteMediaElement(); // Ensure it stays muted }; wavesurfer.on('play', preventPlay); return () => { wavesurfer.un('play', preventPlay); }; }, [wavesurfer]); // Handle drag start on waveform useEffect(() => { if (!wavesurfer || !songDuration || !containerRef.current) return; const container = containerRef.current; let isDraggingLocal = false; const handleMouseDown = (e: MouseEvent) => { if (!wavesurfer) return; const duration = wavesurfer.getDuration(); if (duration <= 0) return; isDraggingLocal = true; setIsDragging(true); // Cancel any pending timeout if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); seekTimeoutRef.current = null; } const rect = container.getBoundingClientRect(); containerPositionRef.current = rect; const clickX = e.clientX - rect.left; const ratio = Math.max(0, Math.min(1, clickX / rect.width)); const seekTime = ratio * duration; lastSeekValueRef.current = seekTime; setTooltipPosition({ x: rect.left + clickX, y: rect.top }); setTooltipValue(seekTime); wavesurfer.seekTo(ratio); }; const handleMouseMove = (e: MouseEvent) => { if (!isDraggingLocal || !wavesurfer) return; const duration = wavesurfer.getDuration(); if (duration <= 0) return; const rect = container.getBoundingClientRect(); containerPositionRef.current = rect; const clickX = e.clientX - rect.left; const ratio = Math.max(0, Math.min(1, clickX / rect.width)); const seekTime = ratio * duration; lastSeekValueRef.current = seekTime; setTooltipPosition({ x: rect.left + clickX, y: rect.top }); setTooltipValue(seekTime); wavesurfer.seekTo(ratio); }; const handleMouseUp = () => { if (!isDraggingLocal || !wavesurfer) return; isDraggingLocal = false; const duration = wavesurfer.getDuration(); const seekTime = wavesurfer.getCurrentTime(); setTooltipPosition(null); if (duration > 0 && seekTime >= 0) { mediaSeekToTimestamp(seekTime); lastSeekValueRef.current = seekTime; // Set a fallback timeout to clear dragging state seekTimeoutRef.current = setTimeout(() => { setIsDragging(false); lastSeekValueRef.current = null; seekTimeoutRef.current = null; }, 1000); } else { setIsDragging(false); } }; // Handle touch events for mobile const handleTouchStart = (e: TouchEvent) => { if (!wavesurfer) return; const duration = wavesurfer.getDuration(); if (duration <= 0) return; isDraggingLocal = true; setIsDragging(true); if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); seekTimeoutRef.current = null; } const touch = e.touches[0]; const rect = container.getBoundingClientRect(); containerPositionRef.current = rect; const clickX = touch.clientX - rect.left; const ratio = Math.max(0, Math.min(1, clickX / rect.width)); const seekTime = ratio * duration; lastSeekValueRef.current = seekTime; setTooltipPosition({ x: rect.left + clickX, y: rect.top }); setTooltipValue(seekTime); wavesurfer.seekTo(ratio); }; const handleTouchMove = (e: TouchEvent) => { if (!isDraggingLocal || !wavesurfer) return; e.preventDefault(); const duration = wavesurfer.getDuration(); if (duration <= 0) return; const touch = e.touches[0]; const rect = container.getBoundingClientRect(); containerPositionRef.current = rect; const clickX = touch.clientX - rect.left; const ratio = Math.max(0, Math.min(1, clickX / rect.width)); const seekTime = ratio * duration; lastSeekValueRef.current = seekTime; setTooltipPosition({ x: rect.left + clickX, y: rect.top }); setTooltipValue(seekTime); wavesurfer.seekTo(ratio); }; const handleTouchEnd = () => { if (!isDraggingLocal || !wavesurfer) return; isDraggingLocal = false; const duration = wavesurfer.getDuration(); const seekTime = wavesurfer.getCurrentTime(); setTooltipPosition(null); if (duration > 0 && seekTime >= 0) { mediaSeekToTimestamp(seekTime); lastSeekValueRef.current = seekTime; seekTimeoutRef.current = setTimeout(() => { setIsDragging(false); lastSeekValueRef.current = null; seekTimeoutRef.current = null; }, 1000); } else { setIsDragging(false); } }; container.addEventListener('mousedown', handleMouseDown); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); container.addEventListener('touchstart', handleTouchStart, { passive: false }); container.addEventListener('touchmove', handleTouchMove, { passive: false }); container.addEventListener('touchend', handleTouchEnd); return () => { container.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchmove', handleTouchMove); container.removeEventListener('touchend', handleTouchEnd); if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); } }; }, [wavesurfer, songDuration, mediaSeekToTimestamp]); // Sync dragging state when currentTime catches up to seek value useEffect(() => { if (isDragging && lastSeekValueRef.current !== null) { const timeDiff = Math.abs(currentTime - lastSeekValueRef.current); if (timeDiff < 0.5) { setIsDragging(false); setTooltipPosition(null); lastSeekValueRef.current = null; if (seekTimeoutRef.current) { clearTimeout(seekTimeoutRef.current); seekTimeoutRef.current = null; } } } }, [currentTime, isDragging]); // Update waveform progress based on player current time (only when not dragging) useEffect(() => { if (!wavesurfer || !songDuration || isDragging) return; const duration = wavesurfer.getDuration(); if (duration > 0 && currentTime >= 0) { const ratio = currentTime / duration; wavesurfer.seekTo(ratio); } }, [wavesurfer, currentTime, songDuration, isDragging]); // Show disabled slider when there's no current song if (!currentSong) { return ( { e?.stopPropagation(); }} size={6} value={0} w="100%" /> ); } return (
{ e?.stopPropagation(); }} style={{ position: 'relative' }} > {isLoading && ( )} {tooltipPosition && isDragging && ( {formatDuration(tooltipValue * 1000)} )}
); };