import React, { useEffect, useMemo, useRef, useState } from 'react'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts'; import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; import { getDndApi } from '../shared/dndApi'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { Button } from '../shared/ui/controls'; import { Surface } from '../shared/ui/Surface'; import styles from './ControlApp.module.css'; import { ControlScenePreview } from './ControlScenePreview'; import { getFreezeEffectLifeMs, playFreezeEffectSound } from './freezeSfx'; import { getPoisonCloudEffectLifeMs, playPoisonCloudEffectSound } from './poisonCloudSfx'; import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx'; /** Длительность визуала молнии (мс). */ const LIGHTNING_EFFECT_MS = 180; 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')}`; } /** Файл из `app/renderer/public/molniya.mp3` — рядом с `control.html` в dev и в dist. */ function lightningEffectSoundUrl(): string { return new URL('molniya.mp3', window.location.href).href; } function playLightningEffectSound(): void { try { const el = new Audio(lightningEffectSoundUrl()); el.volume = 0.88; void el.play().catch(() => undefined); } catch { /* ignore */ } } export function ControlApp() { const api = getDndApi(); const [fxState, fx] = useEffectsState(); const [session, setSession] = useState(null); const historyRef = useRef([]); const suppressNextHistoryPushRef = useRef(false); const [history, setHistory] = useState([]); const audioElsRef = useRef>(new Map()); const audioMetaRef = useRef>(new Map()); const [audioStateTick, setAudioStateTick] = useState(0); const audioLoadRunRef = useRef(0); const audioUnmountRef = useRef(false); const previewHostRef = useRef(null); const previewVideoRef = useRef(null); const brushRef = useRef<{ tool: 'fog' | 'fire' | 'rain' | 'water' | 'lightning' | 'sunbeam' | 'poisonCloud' | 'freeze' | 'eraser'; startN?: { x: number; y: number }; points?: { x: number; y: number; tMs: number }[]; } | null>(null); const [draftFxTick, setDraftFxTick] = useState(0); const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null); const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 }); const [previewContentRect, setPreviewContentRect] = useState<{ x: number; y: number; w: number; h: number; } | null>(null); useEffect(() => { void api.invoke(ipcChannels.project.get, {}).then((res) => { const next: SessionState = { project: res.project, currentSceneId: res.project?.currentSceneId ?? null, }; setSession(next); historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : []; setHistory(historyRef.current); }); return api.on(ipcChannels.session.stateChanged, ({ state }) => { setSession(state); const cur = state.project?.currentGraphNodeId ?? null; if (!cur) return; const arr = historyRef.current; if (suppressNextHistoryPushRef.current) { suppressNextHistoryPushRef.current = false; setHistory(arr); return; } // Если мы перемотались на уже существующий шаг, не дублируем его в истории. if (arr.includes(cur)) { setHistory(arr); return; } if (arr[arr.length - 1] !== cur) { historyRef.current = [...arr, cur]; setHistory(historyRef.current); } }); }, [api]); useEffect(() => { audioUnmountRef.current = false; return () => { audioUnmountRef.current = true; }; }, []); const [freezeDraftLifeMs, setFreezeDraftLifeMs] = useState(820); useEffect(() => { void getFreezeEffectLifeMs().then(setFreezeDraftLifeMs); }, []); const [sunbeamDraftLifeMs, setSunbeamDraftLifeMs] = useState(600); useEffect(() => { void getSunbeamEffectLifeMs().then(setSunbeamDraftLifeMs); }, []); const [poisonDraftLifeMs, setPoisonDraftLifeMs] = useState(1600); useEffect(() => { void getPoisonCloudEffectLifeMs().then(setPoisonDraftLifeMs); }, []); const project = session?.project ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentScene = project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]); const sceneAudios = useMemo(() => { if (!project) return []; return sceneAudioRefs .map((r) => { const a = project.assets[r.assetId]; return a?.type === 'audio' ? { ref: r, asset: a } : null; }) .filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable['asset'] } => Boolean(x), ); }, [project, sceneAudioRefs]); useEffect(() => { audioLoadRunRef.current += 1; const runId = audioLoadRunRef.current; const oldEls = new Map(audioElsRef.current); audioElsRef.current = new Map(); audioMetaRef.current.clear(); setAudioStateTick((x) => x + 1); const FADE_OUT_MS = 450; const fadeOutCtl = { raf: 0, cancelled: false }; const finishFadeOut = (): void => { for (const el of oldEls.values()) { try { el.pause(); el.currentTime = 0; el.volume = 1; } catch { // ignore } } }; if (oldEls.size > 0) { const startVol = new Map(); for (const [id, el] of oldEls) { startVol.set(id, el.volume); } const t0 = performance.now(); const tickOut = (now: number): void => { if (fadeOutCtl.cancelled || audioUnmountRef.current) { finishFadeOut(); return; } const u = Math.min(1, (now - t0) / FADE_OUT_MS); for (const [id, el] of oldEls) { try { const v0 = startVol.get(id) ?? 1; el.volume = v0 * (1 - u); } catch { // ignore } } if (u < 1) { fadeOutCtl.raf = window.requestAnimationFrame(tickOut); } else { finishFadeOut(); } }; fadeOutCtl.raf = window.requestAnimationFrame(tickOut); } if (!project || !currentScene) { return () => { fadeOutCtl.cancelled = true; window.cancelAnimationFrame(fadeOutCtl.raf); }; } const FADE_IN_MS = 550; void (async () => { const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = []; for (const item of sceneAudioRefs) { const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId }); if (audioLoadRunRef.current !== runId) return; if (!r.url) continue; const el = new Audio(r.url); el.loop = item.loop; el.preload = 'auto'; el.volume = item.autoplay ? 0 : 1; audioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setAudioStateTick((x) => x + 1)); el.addEventListener('ended', () => setAudioStateTick((x) => x + 1)); el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1)); el.addEventListener('error', () => setAudioStateTick((x) => x + 1)); loaded.push({ ref: item, el }); audioElsRef.current.set(item.assetId, el); } setAudioStateTick((x) => x + 1); for (const { ref, el } of loaded) { if (audioLoadRunRef.current !== runId) { try { el.pause(); el.currentTime = 0; el.volume = 1; } catch { // ignore } continue; } if (!ref.autoplay) continue; try { await el.play(); } catch { const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', }); setAudioStateTick((x) => x + 1); try { el.volume = 1; } catch { // ignore } continue; } if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { try { el.volume = 1; } catch { // ignore } continue; } const tIn0 = performance.now(); const tickIn = (now: number): void => { if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { try { el.volume = 1; } catch { // ignore } return; } const u = Math.min(1, (now - tIn0) / FADE_IN_MS); try { el.volume = u; } catch { // ignore } if (u < 1) window.requestAnimationFrame(tickIn); }; window.requestAnimationFrame(tickIn); } })(); return () => { fadeOutCtl.cancelled = true; window.cancelAnimationFrame(fadeOutCtl.raf); }; }, [api, currentScene, project, sceneAudioRefs]); const anyPlaying = useMemo(() => { for (const el of audioElsRef.current.values()) { if (!el.paused) return true; } return false; // eslint-disable-next-line react-hooks/exhaustive-deps }, [audioStateTick]); useEffect(() => { if (!anyPlaying) return; let raf = 0; const tick = () => { setAudioStateTick((x) => x + 1); raf = window.requestAnimationFrame(tick); }; raf = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(raf); }, [anyPlaying]); useEffect(() => { const host = previewHostRef.current; if (!host) return; const update = () => { const r = host.getBoundingClientRect(); setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) }); }; update(); const ro = new ResizeObserver(update); ro.observe(host); return () => ro.disconnect(); }, []); function audioStatus(assetId: string): { label: string; detail?: string } { const el = audioElsRef.current.get(assetId) ?? null; if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' }; const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null }; if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError }; if (el.error) return { label: 'Ошибка', detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`, }; if (el.readyState < 2) return { label: 'Загрузка…' }; if (!el.paused) return { label: 'Играет' }; if (el.currentTime > 0) return { label: 'Пауза' }; return { label: 'Остановлено' }; } const nextScenes = useMemo(() => { if (!project) return []; if (!currentGraphNodeId) return []; const outgoing = project.sceneGraphEdges .filter((e) => e.sourceGraphNodeId === currentGraphNodeId) .map((e) => { const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId); return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null; }) .filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x)); return outgoing .map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] })) .filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined); }, [currentGraphNodeId, project]); const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 }; function toNPoint(e: React.PointerEvent): { x: number; y: number } | null { const host = previewHostRef.current; if (!host) return null; const r = host.getBoundingClientRect(); const cr = previewContentRect; const ox = cr ? cr.x : 0; const oy = cr ? cr.y : 0; const cw = cr ? cr.w : r.width; const ch = cr ? cr.h : r.height; const x = (e.clientX - (r.left + ox)) / Math.max(1, cw); const y = (e.clientY - (r.top + oy)) / Math.max(1, ch); return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }; } async function commitStroke(): Promise { if (isVideoPreviewScene) { brushRef.current = null; setDraftFxTick((x) => x + 1); return; } if (!fxState) return; const b = brushRef.current; if (!b) return; const createdAtMs = Date.now(); const seed = Math.floor(Math.random() * 1_000_000_000); if (b.tool === 'fog' && b.points && b.points.length > 0) { await fx.dispatch({ kind: 'instance.add', instance: { id: `fog_${String(createdAtMs)}_${String(seed)}`, type: 'fog', seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.05, Math.min(0.9, tool.intensity)), lifetimeMs: null, }, }); } if (b.tool === 'fire' && b.points && b.points.length > 0) { await fx.dispatch({ kind: 'instance.add', instance: { id: `fire_${String(createdAtMs)}_${String(seed)}`, type: 'fire', seed, createdAtMs, points: b.points, radiusN: tool.radiusN, // Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами. opacity: Math.max(0.12, Math.min(0.95, tool.intensity)), lifetimeMs: null, }, }); } if (b.tool === 'rain' && b.points && b.points.length > 0) { await fx.dispatch({ kind: 'instance.add', instance: { id: `rain_${String(createdAtMs)}_${String(seed)}`, type: 'rain', seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.08, Math.min(0.9, tool.intensity)), lifetimeMs: null, }, }); } if (b.tool === 'water' && b.points && b.points.length > 0) { await fx.dispatch({ kind: 'instance.add', instance: { id: `water_${String(createdAtMs)}_${String(seed)}`, type: 'water', seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.06, Math.min(0.72, tool.intensity * 0.85)), lifetimeMs: null, }, }); } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; const end = { x: last.x, y: last.y }; const start = { x: end.x, y: 0 }; await fx.dispatch({ kind: 'instance.add', instance: { id: `lt_${String(createdAtMs)}_${String(seed)}`, type: 'lightning', seed, createdAtMs, start, end, widthN: Math.max(0.01, tool.radiusN * 0.9), intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), lifetimeMs: LIGHTNING_EFFECT_MS, }, }); await fx.dispatch({ kind: 'instance.add', instance: { id: `sc_${String(createdAtMs)}_${String(seed)}`, type: 'scorch', seed: seed ^ 0x5a5a5a, createdAtMs, at: end, radiusN: Math.max(0.03, tool.radiusN * 0.625), opacity: 0.92, lifetimeMs: 60_000, }, }); playLightningEffectSound(); } if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; const end = { x: last.x, y: last.y }; const start = { x: end.x, y: 0 }; const sunbeamLifeMs = await getSunbeamEffectLifeMs(); await fx.dispatch({ kind: 'instance.add', instance: { id: `sb_${String(createdAtMs)}_${String(seed)}`, type: 'sunbeam', seed, createdAtMs, start, end, widthN: Math.max(0.012, tool.radiusN * 0.95), intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)), lifetimeMs: sunbeamLifeMs, }, }); playSunbeamEffectSound(); } if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; const at = { x: last.x, y: last.y }; const poisonLifeMs = await getPoisonCloudEffectLifeMs(); await fx.dispatch({ kind: 'instance.add', instance: { id: `pc_${String(createdAtMs)}_${String(seed)}`, type: 'poisonCloud', seed, createdAtMs, at, radiusN: Math.max(0.03, tool.radiusN * 0.95), intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)), lifetimeMs: poisonLifeMs, }, }); void playPoisonCloudEffectSound(poisonLifeMs); } if (b.tool === 'freeze' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return; const at = { x: last.x, y: last.y }; const freezeLifeMs = await getFreezeEffectLifeMs(); await fx.dispatch({ kind: 'instance.add', instance: { id: `fr_${String(createdAtMs)}_${String(seed)}`, type: 'freeze', seed, createdAtMs, at, intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), // Длительность как у zamorozka.mp3 (фазы «замерзания» в PxiEffectsOverlay масштабируются по life). lifetimeMs: freezeLifeMs, }, }); await fx.dispatch({ kind: 'instance.add', instance: { id: `ice_${String(createdAtMs)}_${String(seed)}`, type: 'ice', seed: seed ^ 0x33cc99, createdAtMs, at, radiusN: Math.max(0.03, tool.radiusN * 0.9), opacity: 0.85, lifetimeMs: null, }, }); playFreezeEffectSound(); } brushRef.current = null; setDraftFxTick((x) => x + 1); } const draftInstance = useMemo(() => { const b = brushRef.current; if (!b) return null; const seed = 12345; const createdAtMs = Date.now(); if (b.tool === 'fog' && b.points && b.points.length > 0) { return { id: '__draft__', type: 'fog' as const, seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)), lifetimeMs: null, }; } if (b.tool === 'fire' && b.points && b.points.length > 0) { return { id: '__draft__', type: 'fire' as const, seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)), lifetimeMs: null, }; } if (b.tool === 'rain' && b.points && b.points.length > 0) { return { id: '__draft__', type: 'rain' as const, seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)), lifetimeMs: null, }; } if (b.tool === 'water' && b.points && b.points.length > 0) { return { id: '__draft__', type: 'water' as const, seed, createdAtMs, points: b.points, radiusN: tool.radiusN, opacity: Math.max(0.06, Math.min(0.55, tool.intensity * 0.72)), lifetimeMs: null, }; } if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; return { id: '__draft__', type: 'lightning' as const, seed, createdAtMs, start: { x: last.x, y: 0 }, end: { x: last.x, y: last.y }, widthN: Math.max(0.01, tool.radiusN * 0.9), intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)), lifetimeMs: LIGHTNING_EFFECT_MS, }; } if (b.tool === 'sunbeam' && b.startN && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; return { id: '__draft__', type: 'sunbeam' as const, seed, createdAtMs, start: { x: last.x, y: 0 }, end: { x: last.x, y: last.y }, widthN: Math.max(0.012, tool.radiusN * 0.95), intensity: Math.max(0.95, Math.min(1.25, tool.intensity * 1.4)), lifetimeMs: sunbeamDraftLifeMs, }; } if (b.tool === 'poisonCloud' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; return { id: '__draft__', type: 'poisonCloud' as const, seed, createdAtMs, at: { x: last.x, y: last.y }, radiusN: Math.max(0.03, tool.radiusN * 0.95), intensity: Math.max(0.75, Math.min(1.2, tool.intensity * 1.15)), lifetimeMs: poisonDraftLifeMs, }; } if (b.tool === 'freeze' && b.points && b.points.length > 0) { const last = b.points[b.points.length - 1]; if (last === undefined) return null; return { id: '__draft__', type: 'freeze' as const, seed, createdAtMs, at: { x: last.x, y: last.y }, intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)), lifetimeMs: freezeDraftLifeMs, }; } return null; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ draftFxTick, freezeDraftLifeMs, poisonDraftLifeMs, sunbeamDraftLifeMs, tool.intensity, tool.radiusN, tool.tool, ]); const fxMergedState = useMemo(() => { if (!fxState) return null; if (!draftInstance) return fxState; return { ...fxState, instances: [...fxState.instances, draftInstance] }; }, [draftInstance, fxState]); return (
ПУЛЬТ УПРАВЛЕНИЯ
{!isVideoPreviewScene ? ( <>
ЭФФЕКТЫ
Инструменты
Эффекты поля
Эффекты действий
Радиус кисти
{ const v = Number((e.currentTarget as HTMLInputElement).value); const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN)); void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } }); }} className={styles.range} aria-label="Радиус кисти" />
{Math.round(tool.radiusN * 100)}
) : null}
СЮЖЕТНАЯ ЛИНИЯ
{history.map((gnId, idx) => { const gn = project?.sceneGraphNodes.find((n) => n.id === gnId); const s = gn ? project?.scenes[gn.sceneId] : undefined; const isCurrent = gnId === project?.currentGraphNodeId; return ( ); })} {history.length === 0 ?
Нет активной сцены.
: null}
Предпросмотр экрана
{isVideoPreviewScene ? (
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).
) : null}
{!isVideoPreviewScene ? ( <> {cursorN ? (
) : null}
{ const p = toNPoint(e); if (!p) return; setCursorN(p); }} onPointerLeave={() => setCursorN(null)} onPointerDown={(e) => { const p = toNPoint(e); if (!p) return; setCursorN(p); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); if (tool.tool === 'eraser') { const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); if (id) void fx.dispatch({ kind: 'instance.remove', id }); return; } brushRef.current = { tool: tool.tool, startN: p, points: [{ x: p.x, y: p.y, tMs: Date.now() }], }; setDraftFxTick((x) => x + 1); }} onPointerMove={(e) => { const p = toNPoint(e); if (!p) return; setCursorN(p); if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) { const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); if (id) void fx.dispatch({ kind: 'instance.remove', id }); return; } const b = brushRef.current; if (!b?.points) return; const last = b.points[b.points.length - 1]; if (!last) return; const dx = p.x - last.x; const dy = p.y - last.y; const minStep = Math.max(0.004, tool.radiusN * 0.25); if (dx * dx + dy * dy < minStep * minStep) return; b.points.push({ x: p.x, y: p.y, tMs: Date.now() }); setDraftFxTick((x) => x + 1); }} onPointerUp={() => { void commitStroke(); }} onPointerCancel={() => { brushRef.current = null; setDraftFxTick((x) => x + 1); }} /> ) : null}
Варианты ветвления
{nextScenes.map((o, i) => (
ОПЦИЯ {String(i + 1)}
{o.scene.title || 'Без названия'}
))} {nextScenes.length === 0 ? (
Нет вариантов перехода.
) : null}
Музыка
{sceneAudios.length === 0 ? (
В текущей сцене нет аудио.
) : (
{sceneAudios.map(({ ref, asset }) => { const el = audioElsRef.current.get(ref.assetId) ?? null; const st = audioStatus(ref.assetId); const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0; const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0; const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0; return (
{asset.originalName}
{ref.autoplay ? 'Авто' : 'Ручн.'}
{ref.loop ? 'Цикл' : 'Один раз'}
{st.label}
0 ? Math.round(dur) : 0} aria-valuenow={Math.round(cur)} tabIndex={0} onKeyDown={(e) => { if (!el) return; if (!dur) return; if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5); if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5); setAudioStateTick((x) => x + 1); }} onClick={(e) => { if (!el) return; if (!dur) return; const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const next = (e.clientX - rect.left) / rect.width; el.currentTime = Math.max(0, Math.min(dur, next * dur)); setAudioStateTick((x) => x + 1); }} className={[ styles.audioScrub, dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault, ].join(' ')} title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'} >
{formatTime(cur)}
{dur ? formatTime(dur) : '—:—'}
); })}
)}
); }