/* AcreliaVideo.jsx — Video corporativo de producto (versión dinámica)
La plataforma es la protagonista: marco de navegador grande y persistente,
cámara que recorre la UI, etiquetas ancladas, cortes y montaje. Poco texto.
Sincronizado al audio de la voz en off. 1920×1080, auto-escalado. */
const { useState, useRef, useEffect, useCallback } = React;
/* ----------------------------------------------------------- paleta */
const C = {
blue:'#1992C9', blueLt:'#46AEDC', blueDeep:'#0F6E97', blueDark:'#0B4A66',
ink:'#16242E', ink2:'#24333D', navy:'#0B1B25', navy2:'#0E2533',
mist:'#EAF4FA', mist2:'#F3F9FC', white:'#FFFFFF', soft:'#5C6E78', softLt:'rgba(255,255,255,0.66)',
};
const FONT = "'Manrope', system-ui, sans-serif";
const MONO = "ui-monospace, 'SF Mono', Menlo, monospace";
/* --------------------------------------------------------- easing */
const E = {
outCubic: x => 1 - Math.pow(1 - x, 3),
inOutCubic: x => x < 0.5 ? 4*x*x*x : 1 - Math.pow(-2*x+2, 3)/2,
outExpo: x => x >= 1 ? 1 : 1 - Math.pow(2, -10*x),
outQuint: x => 1 - Math.pow(1 - x, 5),
outBack: x => { const c1=1.70158, c3=c1+1; return 1 + c3*Math.pow(x-1,3) + c1*Math.pow(x-1,2); },
};
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const lerp = (a,b,p) => a + (b-a)*p;
function P(lt, delay, dur, ease){ return (ease||E.outCubic)(clamp((lt-(delay||0))/(dur||0.6), 0, 1)); }
function rv(lt, delay, dur, ease, fromY){
const e = P(lt, delay, dur||0.7, ease||E.outCubic);
return { opacity: e, transform: `translateY(${(1-e)*(fromY==null?44:fromY)}px)` };
}
/* ------------------------------------------------------- timeline */
const TL = [
{ key:'intro', s:0.0, e:5.62 }, { key:'plataforma', s:5.62, e:12.12 }, { key:'usable', s:12.12, e:17.52 },
{ key:'crm', s:17.52, e:32.94 }, { key:'editor', s:32.94, e:48.10 }, { key:'ia', s:48.10, e:56.08 },
{ key:'imagenes', s:56.08, e:67.0 }, { key:'home', s:67.0, e:83.82 }, { key:'topbar', s:83.82, e:92.34 },
{ key:'reputacion', s:92.34, e:103.4 }, { key:'idiomas', s:103.4, e:110.7 },
{ key:'cierre', s:110.7, e:125.00 }, { key:'tagline', s:125.00, e:129.18 },
];
const DUR = 129.18;
const AUDIO_SRC = 'uploads/audio_video_acrelia.mp3';
const MUSIC_SRC = 'uploads/music_acrelia.mp3';
/* capturas disponibles (añade aquí cada archivo cuando lo subas a assets/).
Las que no estén en el set muestran placeholder automáticamente.
Para el vídeo del CRM (assets/s4-crm.mp4), poner CLIP_READY = true. */
const READY_FILES = new Set([
'assets/s2-overview.png',
'assets/s4-crm.mp4',
'assets/s2-overview-2.png',
'assets/s2-overview-3.png',
'assets/s5-editor.png',
'assets/s5-editor.mp4',
'assets/s6-ia.mp4',
'assets/s7-imagenes.png',
'assets/s7-imagenes-2.png',
'assets/s7-imagenes-3.png',
'assets/s9-topbar.png',
'assets/s9-topbar.mp4',
'assets/s10-reputacion.png',
'assets/s10-reputacion.mp4',
'assets/s8-home.png',
'assets/s8-home.mp4',
'assets/final-2.png',
'assets/final-1.png',
'assets/final-3.png',
'assets/final-4.png',
'assets/final-5.png',
'assets/final-6.png',
'assets/s2-usable.png',
'assets/s2-usable-dark.png',
]);
const has = (f) => !!f && READY_FILES.has(f);
const shot = (file) => has(file) ? file : null;
const MEDIA = { playing: false }; // estado compartido para el vídeo embebido
const BLOB_CACHE = {}; // path -> objectURL (vídeo descargado en memoria)
const vsrc = (p) => (p && BLOB_CACHE[p]) || p;
const ALL_VIDEOS = ['assets/s4-crm.mp4','assets/s5-editor.mp4','assets/s6-ia.mp4','assets/s8-home.mp4','assets/s9-topbar.mp4','assets/s10-reputacion.mp4'];
/* marco de navegador grande y persistente */
const FR = { x:210, y:128, w:1500, h:892, chrome:60 };
const FRC = FR.h - FR.chrome; // altura del lienzo interior
/* ===================================================== primitivos */
function Chrome() {
return (
);
}
function Placeholder({ title, file, video }) {
return (
{video
?
:
}
{title}
{file}
// arrastra aquí {video ? 'el vídeo' : 'la captura'}
);
}
/* etiquetas apiladas (opción G): bloques sólidos pegados a un lado, acumulativos.
side: 'l' (izquierda) | 'r' (derecha). top: separación desde arriba. */
function TagStack({ lt, items, side, top, gap }) {
const isR = side === 'r';
let active = -1; items.forEach((it,i)=>{ if (lt >= (it.at||0)) active = i; });
return (
{items.map((it,i) => {
const e = clamp(P(lt, it.at||0, 0.5, E.outBack), 0, 1);
const on = i === active;
return (
{it.t}
);
})}
);
}
/* caption inferior (lower-third) sobre scrim */
function Caption({ lt, kicker, title, sub }) {
return (
{kicker ? (
{kicker}
) : null}
{title}
{sub ?
{sub}
: null}
);
}
/* MARCO PRODUCTO — el héroe de casi todas las escenas */
function VideoSlot({ src, lt, fit='contain', fitDur }) {
const ref = useRef(null);
// precarga en cuanto se monta para que no entre "en frío" al cortar la escena
useEffect(() => { const v = ref.current; if (!v) return;
try { v.load(); } catch(e){}
if (MEDIA.playing) v.play().catch(()=>{}); }, []);
useEffect(() => { const v = ref.current; if (!v) return;
if (MEDIA.playing) { if (v.paused) v.play().catch(()=>{}); } else v.pause(); }, [lt]);
// solo corrige la sincronía si la desviación es grande (no reescribir currentTime cada frame: congela el vídeo)
useEffect(() => { const v = ref.current; if (!v || !v.duration) return;
if (fitDur) {
// encaja el vídeo ENTERO dentro de la duración de la escena (acelera/ralentiza un poco)
v.playbackRate = Math.max(0.5, Math.min(2, v.duration / fitDur));
const want = Math.min((clamp(lt, 0, fitDur) / fitDur) * v.duration, v.duration - 0.05);
if (Math.abs(v.currentTime - want) > 0.7) { try { v.currentTime = want; } catch(e){} }
return;
}
const want = lt % v.duration; if (Math.abs(v.currentTime - want) > 0.7) { try { v.currentTime = want; } catch(e){} } }, [lt]);
return ;
}
/* contenido alto simulado para que se vea el scroll en el preview */
function TallPlaceholder({ title, file }) {
const rows = [0,1,2,3,4,5,6];
return (
{title || 'Captura'}
{file}
{rows.map(i => (
))}
// scroll · {file}
);
}
/* pantalla con borde luminoso (glow), con scroll opcional del contenido */
function GlowScreen({ w, h, file, videoFile, title, lt, scrollFrac, dim=0, chrome=true, radius=22, contentScale=1, videoFit='contain', imgFit='contain', fitDur }) {
const ready = videoFile ? has(videoFile) : has(file);
const ch = chrome ? FR.chrome : 0;
const contentH = h - ch;
const scRef = useRef(null);
const isScroll = (scrollFrac != null) && !videoFile;
let ty = 0;
if (isScroll && scRef.current) ty = -clamp(scrollFrac,0,1) * Math.max(0, scRef.current.scrollHeight - contentH);
return (
{chrome ?
: null}
{isScroll ? (
{ready ?
:
}
) : (
{ready ? (videoFile ?
:
)
:
}
)}
{dim > 0 ?
: null}
);
}
const rigStyle = (t, op) => ({ position:'absolute', left:'50%', top:'50%', width:0, height:0, transformStyle:'preserve-3d', transform:t, opacity:op });
/* HÉROE 3D — variantes: 'solo' (una pantalla grande con scroll+zoom),
'wall' (central + 2 laterales) y 'pan' (fila de pantallas que recorre la cámara).
Corta entre varias capturas (files) y hace scroll del contenido. */
function Hero({ lt, dur, file, files, videoFile, title, kicker, capTitle, capSub, cam, dir, variant='solo', scroll=false, videoFit='contain', fitVideo=false, soloH=850, children }) {
const ent = E.outCubic(clamp(lt / 0.95, 0, 1));
const d = dir === 'l' ? -1 : 1;
const p = E.inOutCubic(clamp(lt / (dur || 8), 0, 1));
const float = Math.sin(lt*0.7)*9;
const entZ = (1-ent)*-640, entRot = (1-ent)*20*d;
const list = (files && files.length ? files : [videoFile || file]).filter(Boolean);
const n = Math.max(list.length, 1);
const isV = (f) => typeof f === 'string' && f.endsWith('.mp4');
const cutDur = (dur || 8) / n;
const idx = clamp(Math.floor(lt / cutDur), 0, n-1);
const cutLocal = lt - idx*cutDur;
const cutPop = E.outCubic(clamp(cutLocal/0.5, 0, 1));
const scrollFrac = E.inOutCubic(clamp((cutLocal-0.4)/Math.max(cutDur-0.9,0.5), 0, 1));
const cur = list[idx] || list[0];
const G = (f, w, h, o={}) => ;
let rig;
if (variant === 'wall') {
const camZ = lerp(cam?.z0 ?? -150, cam?.z1 ?? 160, p);
const camRot = lerp(cam?.r0 ?? (6*d), cam?.r1 ?? (-3*d), p) + Math.sin(lt*0.4)*0.6;
const sideA = list[(idx+1)%n] || cur, sideB = list[(idx+2)%n] || cur;
rig = (
{G(sideA,1040,580,{dim:0.58,chrome:false,t:''})}
{G(sideB,1040,580,{dim:0.58,chrome:false,t:''})}
{G(cur,1420,790,{scroll:scroll,cs:lerp(1.04,1.0,cutPop),imgFit:'fill'})}
);
} else if (variant === 'pan') {
const SP = 1480;
// paneo con keyframes: dwell largo en la 2ª captura (-SP) y luego avanza a la 3ª
const camStops = [[0,180],[0.16,0],[0.34,-SP],[0.66,-SP],[0.85,-(n-1)*SP],[1,-(n-1)*SP]];
let camX = camStops[0][1];
for (let k=1;k
{list.map((f,i)=>(
{G(f,1340,730,{scroll:scroll && i>0, sf: clamp(p*0.8 - i*0.22, 0, 1)})}
))}
);
} else { // solo
const camZ = lerp(cam?.z0 ?? -110, cam?.z1 ?? 250, p);
const camRot = lerp(cam?.r0 ?? (7*d), cam?.r1 ?? (-5*d), p) + Math.sin(lt*0.35)*0.7;
rig = (
{G(cur,1540,soloH,{scroll:scroll,cs:lerp(1.03,1.0,cutPop)})}
);
}
return (
<>
{rig}
{children}
>
);
}
/* chip (intro / idiomas) */
function Chip({ children, lt, delay, dark, accent }) {
const e = P(lt, delay||0, 0.55, E.outBack);
return (
{children}
);
}
/* KPI compacto (overlay home) */
function StatPin({ lt, at, x, y, label, value, suffix, accent }) {
const e = P(lt, at, 1.3, E.outQuint);
const enter = P(lt, at, 0.55, E.outCubic);
const disp = (value*e).toFixed(1).replace('.', ',');
return (
);
}
/* anillo de reputación (overlay) */
function Gauge({ lt, at, score, x, y }) {
const e = P(lt, at, 1.5, E.outQuint);
const enter = P(lt, at, 0.6, E.outCubic);
const R=120, CIRC=2*Math.PI*R, frac=(score/100)*e, shown=Math.round(score*e);
return (
);
}
function DriftMarks({ light }) {
return (
);
}
function SparkIcon(){
return (
);
}
/* ====================================================== ESCENAS */
function Intro({ lt }) {
const logoE = P(lt, 0.15, 1.0, E.outBack);
const appear = [1.6, 2.0, 2.4];
const hi = [2.9, 3.7, 4.4];
let active = -1; hi.forEach((s,i)=>{ if (lt >= s) active = i; });
return (
Nueva versión
{['Más intuitiva','Más potente','Más rápida'].map((t,i)=>(
{t}
))}
);
}
const STAR_CSS = (() => {
let parts = [], seed = 20260619;
const rnd = () => { seed = (seed*1103515245 + 12345) & 0x7fffffff; return seed/0x7fffffff; };
for (let i=0;i<64;i++){ const x=(rnd()*100).toFixed(1), y=(rnd()*100).toFixed(1),
s=(rnd()*1.5+0.5).toFixed(1), a=(rnd()*0.45+0.18).toFixed(2);
parts.push(`radial-gradient(${s}px ${s}px at ${x}% ${y}%, rgba(255,255,255,${a}), transparent)`); }
return parts.join(',');
})();
function StageBG({ children, dark }) {
if (dark) return (
);
return (
{children}
);
}
function Plataforma({ lt }) {
return (
);
}
function Usable({ lt }) {
return (
);
}
function Crm({ lt }) {
const tags = [
{ t:'Ordena columnas', at:5.3 },
{ t:'Vistas personalizadas', at:7.3 },
{ t:'Filtros avanzados', at:9.4 },
{ t:'Búsquedas guardadas', at:11.2 },
];
return (
);
}
/* editor de campañas */
function Editor({ lt }) {
const tags = [
{ t:'Más intuitivo', at:4.5 },
{ t:'Más cómodo', at:7.0 },
{ t:'Pantalla completa', at:10.0 },
];
return (
);
}
function Ia({ lt }) {
return (
);
}
function Imagenes({ lt }) {
return (
);
}
function Home({ lt }) {
return (
);
}
function Topbar({ lt }) {
return (
);
}
function Reputacion({ lt }) {
return (
);
}
function Idiomas({ lt }) {
const nuevos = ['Galego','Euskera','Valencià','Português','Italiano'];
const previos = ['Español','English','Català','Français'];
const nt = [2.0, 2.9, 3.8, 4.7, 5.6];
return (
Acrelia habla tu idioma
Ahora en 5 idiomas más
{nuevos.map((t,i)=>{
const e = clamp(P(lt, nt[i], 0.55, E.outBack), 0, 1);
const flash = clamp(1 - (lt-nt[i])/0.55, 0, 1);
const fl = Math.sin((lt+i)*0.9)*4;
return (
{t}
);
})}
{previos.map(t=>(
{t}
))}
);
}
/* cierre: montaje rápido de capturas */
function Cierre({ lt }) {
const shots = [
{ f:'assets/final-1.png', t:'Captura final 1' }, { f:'assets/final-2.png', t:'Captura final 2' },
{ f:'assets/final-3.png', t:'Captura final 3' }, { f:'assets/final-4.png', t:'Captura final 4' },
{ f:'assets/final-5.png', t:'Captura final 5' }, { f:'assets/final-6.png', t:'Captura final 6' },
];
const CUT = 1.7, START = 0.3, SLIDE = 0.66;
const N = shots.length;
const idx = clamp(Math.floor((lt - START) / CUT), 0, N - 1);
const localCut = (lt - START) - idx*CUT;
const slideP = E.inOutCubic(clamp((localCut - (CUT - SLIDE)) / SLIDE, 0, 1));
const pos = clamp(idx + (idx < N-1 ? slideP : 0), 0, N-1);
const endMontage = N*CUT + START;
const showText = lt > endMontage - 0.3;
const AR = 2.02, CW = 1720, CH = Math.round(CW/AR);
return (
{/* montaje — carrete deslizante continuo */}
{shots.map((sh,i)=>(
{shot(sh.f)
?
:
}
))}
{/* texto final */}
{showText ? (
Lanzamiento en Julio
Muchas más mejoras te esperan
) : null}
);
}
function Tagline({ lt }) {
const e = P(lt, 0.15, 1.0, E.outCubic);
return (
El email marketing que evoluciona contigo
);
}
const SCENES = {
intro:Intro, plataforma:Plataforma, usable:Usable, crm:Crm, editor:Editor, ia:Ia,
imagenes:Imagenes, home:Home, topbar:Topbar, reputacion:Reputacion, idiomas:Idiomas, cierre:Cierre, tagline:Tagline,
};
/* ========================================== contenedor + reloj */
function AcreliaVideo() {
const W = 1920, H = 1080;
const audioRef = useRef(null), musicRef = useRef(null), lastT = useRef(0), wrapRef = useRef(null), hideTimer = useRef(null);
const posterRef = useRef(false);
const clockRef = useRef({ base: 0, anchor: 0 }), playingRef = useRef(false);
const [t, setT] = useState(() => { let init = 0;
try {
const sp = new URLSearchParams(location.search);
const qp = parseFloat(sp.get('start'));
if (isFinite(qp)) { init = qp; posterRef.current = true; }
else { const v = parseFloat(localStorage.getItem('acrelia_video_t') || '0'); if (isFinite(v)) init = v; }
} catch(e){}
lastT.current = init; clockRef.current.base = init; return init; });
const [playing, setPlaying] = useState(false);
const [scale, setScale] = useState(1);
const [blobN, setBlobN] = useState(() => ALL_VIDEOS.filter(p => BLOB_CACHE[p]).length);
const [warmDone, setWarmDone] = useState(false);
// descarga los vídeos a memoria (blob) una sola vez: reproducción sin tirones ni pantallas en blanco
useEffect(() => {
let alive = true;
(async () => {
for (const p of ALL_VIDEOS) {
if (BLOB_CACHE[p]) continue;
try {
const r = await fetch(p); if (!r.ok) continue;
const url = URL.createObjectURL(await r.blob());
if (!alive) { URL.revokeObjectURL(url); return; }
BLOB_CACHE[p] = url; setBlobN(n => n + 1);
} catch (e) { /* sigue con el resto */ }
}
if (alive) setWarmDone(true); // se han intentado todos (aunque alguno falle)
})();
return () => { alive = false; };
}, []);
const [showUI, setShowUI] = useState(true);
const [musicOn, setMusicOn] = useState(true);
const [recMode, setRecMode] = useState(false);
const [recArmed, setRecArmed] = useState(false);
const [recCount, setRecCount] = useState(0);
const recPlayTimer = useRef(null);
const recScheduled = useRef(false);
const recIv = useRef(null);
useEffect(() => { MEDIA.playing = playing; playingRef.current = playing; }, [playing]);
useEffect(() => {
const fit = () => { const el = wrapRef.current; if (!el) return; setScale(Math.min(el.clientWidth / W, el.clientHeight / H)); };
fit(); const ro = new ResizeObserver(fit); if (wrapRef.current) ro.observe(wrapRef.current);
window.addEventListener('resize', fit); return () => { ro.disconnect(); window.removeEventListener('resize', fit); };
}, []);
// El audio es la fuente del reloj mientras avanza (sonido continuo, nunca se reescribe currentTime).
// Solo si el audio se queda bloqueado, el reloj real mueve la imagen y se reintenta reproducir.
useEffect(() => {
let raf, prevAudio = -1, stuck = 0, lastRetry = 0;
const stop = (nt) => { playingRef.current = false; MEDIA.playing = false; setPlaying(false);
const a = audioRef.current; if (a) a.pause(); musicRef.current && musicRef.current.pause(); lastT.current = nt; setT(nt); };
const loop = () => {
if (playingRef.current) {
const a = audioRef.current, c = clockRef.current;
const wallT = c.base + (performance.now() - c.anchor) / 1000;
if (a && !a.paused) {
const at = a.currentTime;
if (Math.abs(at - prevAudio) > 0.004) stuck = 0; else stuck++;
prevAudio = at;
if (stuck < 24) { // audio sano -> manda el audio, sin tocar currentTime
clockRef.current = { base: at, anchor: performance.now() };
if (at >= DUR - 0.04) { stop(DUR); } else { lastT.current = at; setT(at); }
raf = requestAnimationFrame(loop); return;
}
}
// fallback: audio ausente/bloqueado -> avanza la imagen y reintenta el sonido (sin reescribir currentTime)
if (wallT >= DUR) { stop(DUR); } else { lastT.current = wallT; setT(wallT);
if (a && a.paused) { const now = performance.now(); if (now - lastRetry > 700) { lastRetry = now;
const p = a.play(); if (p && p.catch) p.catch(()=>{}); } } }
} else { prevAudio = -1; stuck = 0; }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf);
}, []);
useEffect(() => {
const a = audioRef.current; if (!a) return;
const onMeta = () => { const v = parseFloat(localStorage.getItem('acrelia_video_t') || '0');
if (isFinite(v) && v > 0 && v < a.duration) { a.currentTime = v; if (musicRef.current) musicRef.current.currentTime = v; } };
if (a.readyState >= 1) onMeta(); else a.addEventListener('loadedmetadata', onMeta);
return () => a.removeEventListener('loadedmetadata', onMeta);
}, []);
useEffect(() => { const id = setInterval(() => { localStorage.setItem('acrelia_video_t', String(lastT.current)); }, 500); return () => clearInterval(id); }, []);
const startAudio = (el, to) => { if (!el) return; const needSeek = Math.abs(el.currentTime - to) > 0.3;
const p = el.play();
if (p && p.then) p.then(() => { if (needSeek) { try { el.currentTime = to; } catch(e){} } }).catch(()=>{});
else if (needSeek) { try { el.currentTime = to; } catch(e){} } };
const play = useCallback(() => {
let start = lastT.current >= DUR ? 0 : lastT.current;
if (posterRef.current) { start = 0; posterRef.current = false; }
clockRef.current = { base: start, anchor: performance.now() };
lastT.current = start; setT(start);
playingRef.current = true; MEDIA.playing = true; setPlaying(true);
startAudio(audioRef.current, start);
if (musicRef.current && musicOn) { musicRef.current.volume = 0.16; startAudio(musicRef.current, start); }
}, [musicOn]);
const pause = useCallback(() => { audioRef.current && audioRef.current.pause(); musicRef.current && musicRef.current.pause();
playingRef.current = false; MEDIA.playing = false; setPlaying(false); }, []);
const toggle = useCallback(() => { if (playingRef.current) pause(); else play(); }, [play, pause]);
const seek = useCallback((time) => { const tt = clamp(time, 0, DUR); posterRef.current = false;
clockRef.current = { base: tt, anchor: performance.now() }; lastT.current = tt; setT(tt);
const a = audioRef.current; if (a) { try { a.currentTime = tt; } catch(e){} }
if (musicRef.current) { try { musicRef.current.currentTime = tt; } catch(e){} } }, []);
useEffect(() => { const a = audioRef.current; if (!a) return;
const onEnd = () => { setPlaying(false); musicRef.current && musicRef.current.pause(); };
a.addEventListener('ended', onEnd); return () => a.removeEventListener('ended', onEnd); }, []);
// REC: muestra el preloader y, tras cargar, hace cuenta atrás de 10 s antes de arrancar (para iniciar la captura)
useEffect(() => {
if (!recArmed) return;
const ready = blobN >= ALL_VIDEOS.length || warmDone;
if (ready) {
if (recScheduled.current) return;
recScheduled.current = true;
setRecCount(-1); // fase "Preparando grabación" (sin cuenta atrás todavía)
recPlayTimer.current = setTimeout(() => {
setRecCount(10); // empieza la cuenta atrás tras 3 s
recIv.current = setInterval(() => setRecCount(c => (c > 1 ? c - 1 : 0)), 1000);
recPlayTimer.current = setTimeout(() => {
clearInterval(recIv.current); recScheduled.current = false;
setRecArmed(false); // oculta el preloader (fade 0.6 s)
// arranca el vídeo SOLO cuando el preloader ya se ha desvanecido del todo
recPlayTimer.current = setTimeout(() => { if (!playingRef.current) play(); }, 800);
}, 10000);
}, 3000);
return;
}
const fallback = setTimeout(() => { recScheduled.current = false; setRecArmed(false); if (!playingRef.current) play(); }, 60000);
return () => clearTimeout(fallback);
}, [recArmed, blobN, warmDone, play]);
useEffect(() => {
const onKey = (e) => { if (e.code === 'Space') { e.preventDefault(); toggle(); }
else if (e.code === 'ArrowRight') seek((audioRef.current?.currentTime||0) + 5);
else if (e.code === 'ArrowLeft') seek((audioRef.current?.currentTime||0) - 5);
else if (e.key === '0') seek(0);
else if (e.key === 'Escape') { clearTimeout(recPlayTimer.current); clearInterval(recIv.current); recScheduled.current = false; setRecArmed(false); setRecMode(false); setShowUI(true); } };
window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [toggle, seek]);
const bumpUI = useCallback(() => { setShowUI(true); clearTimeout(hideTimer.current);
if (playing) hideTimer.current = setTimeout(() => setShowUI(false), 2600); }, [playing]);
useEffect(() => { bumpUI(); }, [playing, bumpUI]);
const fmt = (x) => { x = Math.max(0, x); const m = Math.floor(x/60), s = Math.floor(x%60); return m + ':' + String(s).padStart(2,'0'); };
const fmtMs = (x) => { x = Math.max(0, x); const m = Math.floor(x/60), s = Math.floor(x%60), d = Math.floor((x*10)%10); return m + ':' + String(s).padStart(2,'0') + '.' + d; };
const dispT = posterRef.current ? 0 : t;
return (
{TL.map(({ key, s, e }) => {
const fade = 0.42;
if (t < s - fade - 0.9 || t > e + 0.25) return null;
const op = Math.min(clamp((t - (s - fade)) / fade, 0, 1), clamp((e - t) / fade, 0, 1));
const Comp = SCENES[key];
return (
);
})}
{(!playing && t < 0.4 && !recMode) && (
)}
{playing ?
: }
{fmtMs(dispT)}
seek(parseFloat(ev.target.value))}
style={{ flex:1, accentColor:C.blue, height:6, cursor:'pointer' }} />
{fmt(DUR)}
{ setMusicOn(m=>{ const nv=!m; if(musicRef.current){ if(nv && playing){musicRef.current.volume=0.16; musicRef.current.play().catch(()=>{});} else musicRef.current.pause(); } return nv; }); }}
style={{ ...ctrlBtn, width:44, height:44, fontFamily:FONT, fontSize:15, fontWeight:700 }}>{musicOn ? '♪' : '✕'}
{ const el = wrapRef.current;
if (window.self !== window.top) { try { window.parent.postMessage({ type:'acrelia-fs-toggle' }, '*'); } catch(e){} return; }
if (document.fullscreenElement) { document.exitFullscreen().catch(()=>{}); }
else if (el && el.requestFullscreen) { el.requestFullscreen().catch(()=>{}); } }}
aria-label="Pantalla completa" title="Pantalla completa"
style={{ ...ctrlBtn, width:44, height:44 }}>
⛶
{/* preloader: descarga de vídeos a memoria */}
{recArmed ? 'Preparando grabación' : 'Preparando vídeo'}
{(recArmed && (blobN >= ALL_VIDEOS.length || warmDone))
? (recCount < 0 ? 'Listo' : recCount > 0 ? 'Empieza en ' + recCount + ' s…' : 'Arrancando…')
: Cargando clips {blobN} / {ALL_VIDEOS.length} }
= ALL_VIDEOS.length || warmDone)) ? (recCount < 0 ? 0 : (10-recCount)/10) : blobN/ALL_VIDEOS.length)*100+'%', height:'100%', background:'#1992C9', borderRadius:3, transition:'width .4s ease' }}>
);
}
const ctrlBtn = { width:50, height:50, borderRadius:25, border:'1px solid rgba(255,255,255,0.18)', background:'rgba(255,255,255,0.10)',
cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', color:'#fff', flex:'none' };
const bar = { width:5, height:18, background:'#fff', borderRadius:1, display:'block' };
if (typeof module !== 'undefined' && module.exports) module.exports = { AcreliaVideo };
window.AcreliaVideo = AcreliaVideo;