import React, { useEffect, useRef, useState } from 'react'; import { Button } from '../shared/ui/controls'; import styles from './ControlApp.module.css'; function formatTime(sec: number): string { if (!Number.isFinite(sec) || sec < 0) return '0:00'; const s = Math.floor(sec); const m = Math.floor(s / 60); const r = s % 60; return `${String(m)}:${String(r).padStart(2, '0')}`; } function clampAudioGain(v: number): number { if (!Number.isFinite(v)) return 1; return Math.max(0, Math.min(1, v)); } function VolumeSpeakerIcon({ gain }: { gain: number }) { if (gain <= 0.001) { return ( ); } if (gain < 0.5) { return ( ); } return ( ); } export type ControlAudioCardProps = { assetId: string; name: string; autoplay: boolean; loop: boolean; statusLabel: string; statusDetail?: string; extraBadge?: React.ReactNode; audioEl: HTMLAudioElement | null; initialGain: number; gainMap: Map; playTitle: string; playLabel: string; pauseLabel: string; stopLabel: string; volumeLabel: string; modeAutoLabel: string; modeManualLabel: string; loopLabel: string; onceLabel: string; scrubSeekLabel: string; durationUnknownLabel: string; /** Редкий bump родителя (play/pause/error) — не для scrub. */ onStatusChange: () => void; onPlay: () => void; onPause: () => void; onStop: () => void; }; /** * Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp. */ export function ControlAudioCard({ assetId, name, autoplay, loop, statusLabel, statusDetail, extraBadge, audioEl, initialGain, gainMap, playTitle, playLabel, pauseLabel, stopLabel, volumeLabel, modeAutoLabel, modeManualLabel, loopLabel, onceLabel, scrubSeekLabel, durationUnknownLabel, onStatusChange, onPlay, onPause, onStop, }: ControlAudioCardProps) { const scrubRef = useRef(null); const scrubFillRef = useRef(null); const curTimeRef = useRef(null); const durTimeRef = useRef(null); const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain)); const onStatusChangeRef = useRef(onStatusChange); onStatusChangeRef.current = onStatusChange; useEffect(() => { setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain)); }, [assetId, gainMap, initialGain]); useEffect(() => { if (!audioEl) return; let raf = 0; const paint = (): void => { const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0; const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; if (scrubFillRef.current) { scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; } if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur); if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—'; if (scrubRef.current) { scrubRef.current.setAttribute('aria-valuemin', '0'); scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0)); scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur))); scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel; scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0); scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0); } }; const stopLoop = (): void => { if (raf !== 0) { window.cancelAnimationFrame(raf); raf = 0; } }; const loopPaint = (): void => { paint(); if (!audioEl.paused) { raf = window.requestAnimationFrame(loopPaint); } else { raf = 0; } }; const startLoop = (): void => { stopLoop(); raf = window.requestAnimationFrame(loopPaint); }; const onPlayEv = (): void => { startLoop(); onStatusChangeRef.current(); }; const onPauseEv = (): void => { stopLoop(); paint(); onStatusChangeRef.current(); }; const onEndedEv = (): void => { stopLoop(); paint(); onStatusChangeRef.current(); }; const onMetaEv = (): void => { paint(); onStatusChangeRef.current(); }; audioEl.addEventListener('play', onPlayEv); audioEl.addEventListener('pause', onPauseEv); audioEl.addEventListener('ended', onEndedEv); audioEl.addEventListener('canplay', onMetaEv); audioEl.addEventListener('error', onMetaEv); paint(); if (!audioEl.paused) startLoop(); return () => { stopLoop(); audioEl.removeEventListener('play', onPlayEv); audioEl.removeEventListener('pause', onPauseEv); audioEl.removeEventListener('ended', onEndedEv); audioEl.removeEventListener('canplay', onMetaEv); audioEl.removeEventListener('error', onMetaEv); }; }, [audioEl, durationUnknownLabel, scrubSeekLabel]); const seekByClientX = (clientX: number): void => { if (!audioEl || !scrubRef.current) return; const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; if (!dur) return; const rect = scrubRef.current.getBoundingClientRect(); const next = (clientX - rect.left) / Math.max(1, rect.width); audioEl.currentTime = Math.max(0, Math.min(dur, next * dur)); const cur = audioEl.currentTime; const pct = Math.max(0, Math.min(1, cur / dur)); if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur); }; const applyGain = (v: number): void => { const g = clampAudioGain(v); gainMap.set(assetId, g); if (audioEl) { try { audioEl.volume = g; } catch { // ignore } } setGainUi(g); }; return (
{name}
{autoplay ? modeAutoLabel : modeManualLabel}
{loop ? loopLabel : onceLabel}
{statusLabel}
{extraBadge}
{ if (!audioEl) return; const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0; if (!dur) return; if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5); if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5); if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime); const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur)); if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`; }} onClick={(e) => seekByClientX(e.clientX)} >
0:00
—:—
applyGain(Number(e.currentTarget.value))} />
); }