774 lines
25 KiB
JavaScript
774 lines
25 KiB
JavaScript
/* BEGIN USAGE */
|
|
// animations.jsx
|
|
// Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
|
|
// Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
|
|
// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
|
|
//
|
|
// Usage (in an HTML file that loads React + Babel):
|
|
//
|
|
// <Stage width={1280} height={720} duration={10} background="#f6f4ef">
|
|
// <MyScene />
|
|
// </Stage>
|
|
//
|
|
// <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
|
|
// ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
|
|
// Set the optional `poster` prop (seconds) to the moment your opening scene is
|
|
// fully composed — the product thumbnail freezes on that frame (default ~1s).
|
|
// Inside <Stage>, any child can call useTime() to read the current
|
|
// playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
|
|
// to only render during that window -- children receive a `localTime` and
|
|
// `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
|
|
// for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
|
|
// Build YOUR scenes by composing Sprites inside a Stage.
|
|
/* END USAGE */
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
|
|
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
|
|
const Easing = {
|
|
linear: (t) => t,
|
|
|
|
// Quad
|
|
easeInQuad: (t) => t * t,
|
|
easeOutQuad: (t) => t * (2 - t),
|
|
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
|
|
// Cubic
|
|
easeInCubic: (t) => t * t * t,
|
|
easeOutCubic: (t) => (--t) * t * t + 1,
|
|
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
|
|
|
|
// Quart
|
|
easeInQuart: (t) => t * t * t * t,
|
|
easeOutQuart: (t) => 1 - (--t) * t * t * t,
|
|
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
|
|
|
|
// Expo
|
|
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
|
|
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
|
easeInOutExpo: (t) => {
|
|
if (t === 0) return 0;
|
|
if (t === 1) return 1;
|
|
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
|
|
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
|
|
},
|
|
|
|
// Sine
|
|
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
|
|
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
|
|
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
|
|
|
// Back (overshoot)
|
|
easeOutBack: (t) => {
|
|
const c1 = 1.70158, c3 = c1 + 1;
|
|
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
},
|
|
easeInBack: (t) => {
|
|
const c1 = 1.70158, c3 = c1 + 1;
|
|
return c3 * t * t * t - c1 * t * t;
|
|
},
|
|
easeInOutBack: (t) => {
|
|
const c1 = 1.70158, c2 = c1 * 1.525;
|
|
return t < 0.5
|
|
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
|
|
},
|
|
|
|
// Elastic
|
|
easeOutElastic: (t) => {
|
|
const c4 = (2 * Math.PI) / 3;
|
|
if (t === 0) return 0;
|
|
if (t === 1) return 1;
|
|
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
|
},
|
|
};
|
|
|
|
// ── Core interpolation helpers ──────────────────────────────────────────────
|
|
|
|
// Clamp a value to [min, max]
|
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
|
|
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
|
|
// Popmotion-style: linearly maps t across input keyframes to output values,
|
|
// with optional easing per segment (single fn or array of fns).
|
|
function interpolate(input, output, ease = Easing.linear) {
|
|
return (t) => {
|
|
if (t <= input[0]) return output[0];
|
|
if (t >= input[input.length - 1]) return output[output.length - 1];
|
|
for (let i = 0; i < input.length - 1; i++) {
|
|
if (t >= input[i] && t <= input[i + 1]) {
|
|
const span = input[i + 1] - input[i];
|
|
const local = span === 0 ? 0 : (t - input[i]) / span;
|
|
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
|
|
const eased = easeFn(local);
|
|
return output[i] + (output[i + 1] - output[i]) * eased;
|
|
}
|
|
}
|
|
return output[output.length - 1];
|
|
};
|
|
}
|
|
|
|
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
|
|
// Returns `from` before `start`, `to` after `end`.
|
|
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
|
|
return (t) => {
|
|
if (t <= start) return from;
|
|
if (t >= end) return to;
|
|
const local = (t - start) / (end - start);
|
|
return from + (to - from) * ease(local);
|
|
};
|
|
}
|
|
|
|
// ── Timeline context ────────────────────────────────────────────────────────
|
|
|
|
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
|
|
|
|
const useTime = () => React.useContext(TimelineContext).time;
|
|
const useTimeline = () => React.useContext(TimelineContext);
|
|
|
|
// ── Sprite ──────────────────────────────────────────────────────────────────
|
|
// Renders children only when the playhead is inside [start, end]. Provides
|
|
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
|
|
//
|
|
// <Sprite start={2} end={5}>
|
|
// {({ localTime, progress }) => <Thing x={progress * 100} />}
|
|
// </Sprite>
|
|
//
|
|
// Or as a plain wrapper — children can call useSprite() themselves.
|
|
|
|
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
|
|
const useSprite = () => React.useContext(SpriteContext);
|
|
|
|
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
|
|
const { time } = useTimeline();
|
|
const visible = time >= start && time <= end;
|
|
if (!visible && !keepMounted) return null;
|
|
|
|
const duration = end - start;
|
|
const localTime = Math.max(0, time - start);
|
|
const progress = duration > 0 && isFinite(duration)
|
|
? clamp(localTime / duration, 0, 1)
|
|
: 0;
|
|
|
|
const value = { localTime, progress, duration, visible };
|
|
|
|
return (
|
|
<SpriteContext.Provider value={value}>
|
|
{typeof children === 'function' ? children(value) : children}
|
|
</SpriteContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ── Sample sprite components ────────────────────────────────────────────────
|
|
|
|
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
|
|
// Props: text, x, y, size, color, font, entryDur, exitDur, align
|
|
function TextSprite({
|
|
text,
|
|
x = 0, y = 0,
|
|
size = 48,
|
|
color = '#111',
|
|
font = 'Inter, system-ui, sans-serif',
|
|
weight = 600,
|
|
entryDur = 0.45,
|
|
exitDur = 0.35,
|
|
entryEase = Easing.easeOutBack,
|
|
exitEase = Easing.easeInCubic,
|
|
align = 'left',
|
|
letterSpacing = '-0.01em',
|
|
}) {
|
|
const { localTime, duration } = useSprite();
|
|
const exitStart = Math.max(0, duration - exitDur);
|
|
|
|
let opacity = 1;
|
|
let ty = 0;
|
|
|
|
if (localTime < entryDur) {
|
|
const t = entryEase(clamp(localTime / entryDur, 0, 1));
|
|
opacity = t;
|
|
ty = (1 - t) * 16;
|
|
} else if (localTime > exitStart) {
|
|
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
opacity = 1 - t;
|
|
ty = -t * 8;
|
|
}
|
|
|
|
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: x, top: y,
|
|
transform: `translate(${translateX}, ${ty}px)`,
|
|
opacity,
|
|
fontFamily: font,
|
|
fontSize: size,
|
|
fontWeight: weight,
|
|
color,
|
|
letterSpacing,
|
|
whiteSpace: 'pre',
|
|
lineHeight: 1.1,
|
|
willChange: 'transform, opacity',
|
|
}}>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
|
|
function ImageSprite({
|
|
src,
|
|
x = 0, y = 0,
|
|
width = 400, height = 300,
|
|
entryDur = 0.6,
|
|
exitDur = 0.4,
|
|
kenBurns = false,
|
|
kenBurnsScale = 1.08,
|
|
radius = 12,
|
|
fit = 'cover',
|
|
placeholder = null, // {label: string} for striped placeholder
|
|
}) {
|
|
const { localTime, duration } = useSprite();
|
|
const exitStart = Math.max(0, duration - exitDur);
|
|
|
|
let opacity = 1;
|
|
let scale = 1;
|
|
|
|
if (localTime < entryDur) {
|
|
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
|
|
opacity = t;
|
|
scale = 0.96 + 0.04 * t;
|
|
} else if (localTime > exitStart) {
|
|
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
opacity = 1 - t;
|
|
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
|
|
} else if (kenBurns) {
|
|
const holdSpan = exitStart - entryDur;
|
|
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
|
|
scale = 1 + (kenBurnsScale - 1) * holdT;
|
|
}
|
|
|
|
const content = placeholder ? (
|
|
<div style={{
|
|
width: '100%', height: '100%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
|
|
color: '#6b6458',
|
|
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
|
|
fontSize: 13,
|
|
letterSpacing: '0.04em',
|
|
textTransform: 'uppercase',
|
|
}}>
|
|
{placeholder.label || 'image'}
|
|
</div>
|
|
) : (
|
|
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
|
|
);
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: x, top: y,
|
|
width, height,
|
|
opacity,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'center',
|
|
borderRadius: radius,
|
|
overflow: 'hidden',
|
|
willChange: 'transform, opacity',
|
|
}}>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// RectSprite: simple rectangle that animates position/size/color via props.
|
|
// Useful demo primitive — takes a `render` fn for per-frame customization.
|
|
function RectSprite({
|
|
x = 0, y = 0,
|
|
width = 100, height = 100,
|
|
color = '#111',
|
|
radius = 8,
|
|
entryDur = 0.4,
|
|
exitDur = 0.3,
|
|
render, // optional: (ctx) => style overrides
|
|
}) {
|
|
const spriteCtx = useSprite();
|
|
const { localTime, duration } = spriteCtx;
|
|
const exitStart = Math.max(0, duration - exitDur);
|
|
|
|
let opacity = 1;
|
|
let scale = 1;
|
|
|
|
if (localTime < entryDur) {
|
|
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
|
|
opacity = clamp(localTime / entryDur, 0, 1);
|
|
scale = 0.4 + 0.6 * t;
|
|
} else if (localTime > exitStart) {
|
|
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
|
|
opacity = 1 - t;
|
|
scale = 1 - 0.15 * t;
|
|
}
|
|
|
|
const overrides = render ? render(spriteCtx) : {};
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: x, top: y,
|
|
width, height,
|
|
background: color,
|
|
borderRadius: radius,
|
|
opacity,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'center',
|
|
willChange: 'transform, opacity',
|
|
...overrides,
|
|
}} />
|
|
);
|
|
}
|
|
|
|
|
|
function Stage({
|
|
width = 1280,
|
|
height = 720,
|
|
duration = 10,
|
|
background = '#f6f4ef',
|
|
fps = 60,
|
|
loop = true,
|
|
autoplay = true,
|
|
poster = null,
|
|
persistKey = 'animstage',
|
|
children,
|
|
}) {
|
|
// Thumbnail capture mode: the host appends ?thumbnail=1 before screenshotting.
|
|
// Freeze on a representative still — the author-declared `poster` second, or
|
|
// ~1s as a fallback — paused, with the playback bar hidden, so the product
|
|
// thumbnail is a deterministic frame of the first composed scene.
|
|
const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
|
|
|
|
const [time, setTime] = React.useState(() => {
|
|
if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
|
|
try {
|
|
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
|
|
return isFinite(v) ? clamp(v, 0, duration) : 0;
|
|
} catch { return 0; }
|
|
});
|
|
const [playing, setPlaying] = React.useState(captureMode ? false : autoplay);
|
|
const [scale, setScale] = React.useState(1);
|
|
|
|
const stageRef = React.useRef(null);
|
|
const canvasRef = React.useRef(null);
|
|
const rafRef = React.useRef(null);
|
|
const lastTsRef = React.useRef(null);
|
|
|
|
// Persist playhead
|
|
React.useEffect(() => {
|
|
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
|
|
}, [time, persistKey]);
|
|
|
|
// Auto-scale to fit viewport
|
|
React.useEffect(() => {
|
|
if (!stageRef.current) return;
|
|
const el = stageRef.current;
|
|
const measure = () => {
|
|
const barH = captureMode ? 0 : 44; // playback bar height (hidden in capture mode)
|
|
const s = Math.min(
|
|
el.clientWidth / width,
|
|
(el.clientHeight - barH) / height
|
|
);
|
|
setScale(Math.max(0.05, s));
|
|
};
|
|
measure();
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
window.addEventListener('resize', measure);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener('resize', measure);
|
|
};
|
|
}, [width, height]);
|
|
|
|
// Animation loop
|
|
React.useEffect(() => {
|
|
if (!playing) {
|
|
lastTsRef.current = null;
|
|
return;
|
|
}
|
|
const step = (ts) => {
|
|
if (lastTsRef.current == null) lastTsRef.current = ts;
|
|
const dt = (ts - lastTsRef.current) / 1000;
|
|
lastTsRef.current = ts;
|
|
setTime((t) => {
|
|
let next = t + dt;
|
|
if (next >= duration) {
|
|
if (loop) next = next % duration;
|
|
else { next = duration; setPlaying(false); }
|
|
}
|
|
return next;
|
|
});
|
|
rafRef.current = requestAnimationFrame(step);
|
|
};
|
|
rafRef.current = requestAnimationFrame(step);
|
|
return () => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
lastTsRef.current = null;
|
|
};
|
|
}, [playing, duration, loop]);
|
|
|
|
// Keyboard: space = play/pause, ← → = seek
|
|
React.useEffect(() => {
|
|
const onKey = (e) => {
|
|
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
setPlaying(p => !p);
|
|
} else if (e.code === 'ArrowLeft') {
|
|
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
|
|
} else if (e.code === 'ArrowRight') {
|
|
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
|
|
} else if (e.key === '0' || e.code === 'Home') {
|
|
setTime(0);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [duration]);
|
|
|
|
const ctxValue = React.useMemo(
|
|
() => ({ time, duration, playing, setTime, setPlaying }),
|
|
[time, duration, playing]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={stageRef}
|
|
style={{
|
|
position: 'absolute', inset: 0,
|
|
display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center',
|
|
background: '#0a0a0a',
|
|
fontFamily: 'Inter, system-ui, sans-serif',
|
|
}}
|
|
>
|
|
{/* Canvas area — vertically centered in remaining space */}
|
|
<div style={{
|
|
flex: 1,
|
|
width: '100%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
minHeight: 0,
|
|
}}>
|
|
<div
|
|
ref={canvasRef}
|
|
style={{
|
|
width, height,
|
|
background,
|
|
position: 'relative',
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'center',
|
|
flexShrink: 0,
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<TimelineContext.Provider value={ctxValue}>
|
|
{children}
|
|
</TimelineContext.Provider>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Playback bar — stacked below canvas, never overlapping. Hidden in
|
|
capture mode so the thumbnail is just the frame, no chrome. */}
|
|
{!captureMode && (
|
|
<PlaybackBar
|
|
time={time}
|
|
duration={duration}
|
|
playing={playing}
|
|
onPlayPause={() => setPlaying(p => !p)}
|
|
onReset={() => { setTime(0); }}
|
|
onSeek={(t) => setTime(t)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Playback bar ────────────────────────────────────────────────────────────
|
|
// Play/pause, return-to-begin, scrub track, time display.
|
|
// Uses fixed-width time fields so layout doesn't thrash.
|
|
|
|
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek }) {
|
|
const trackRef = React.useRef(null);
|
|
const [dragging, setDragging] = React.useState(false);
|
|
const [trackHover, setTrackHover] = React.useState(null); // { x, t } — px within track + hovered time
|
|
|
|
const posFromEvent = React.useCallback((e) => {
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
const x = clamp(e.clientX - rect.left, 0, rect.width);
|
|
const t = rect.width > 0 ? (x / rect.width) * duration : 0;
|
|
return { x, t };
|
|
}, [duration]);
|
|
|
|
const onTrackMove = (e) => {
|
|
if (!trackRef.current) return;
|
|
const { x, t } = posFromEvent(e);
|
|
setTrackHover({ x, t });
|
|
if (dragging) onSeek(t);
|
|
};
|
|
|
|
const onTrackLeave = () => {
|
|
if (!dragging) setTrackHover(null);
|
|
};
|
|
|
|
const onTrackDown = (e) => {
|
|
const { x, t } = posFromEvent(e);
|
|
setDragging(true);
|
|
setTrackHover({ x, t });
|
|
onSeek(t);
|
|
};
|
|
|
|
// Grab the knob in place: begin dragging without seeking (no jump).
|
|
// stopPropagation keeps the track's click-to-seek from also firing.
|
|
const onBallDown = (e) => {
|
|
e.stopPropagation();
|
|
setDragging(true);
|
|
setTrackHover(posFromEvent(e));
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!dragging) return;
|
|
const prevCursor = document.body.style.cursor;
|
|
document.body.style.cursor = 'grabbing'; // stays grabbing even if the pointer leaves the knob mid-drag
|
|
const onUp = () => {
|
|
setDragging(false);
|
|
setTrackHover(null);
|
|
};
|
|
const onMove = (e) => {
|
|
if (!trackRef.current) return;
|
|
const { x, t } = posFromEvent(e);
|
|
setTrackHover({ x, t });
|
|
onSeek(t);
|
|
};
|
|
window.addEventListener('mouseup', onUp);
|
|
window.addEventListener('mousemove', onMove);
|
|
return () => {
|
|
window.removeEventListener('mouseup', onUp);
|
|
window.removeEventListener('mousemove', onMove);
|
|
document.body.style.cursor = prevCursor;
|
|
};
|
|
}, [dragging, posFromEvent, onSeek]);
|
|
|
|
const pct = duration > 0 ? (time / duration) * 100 : 0;
|
|
const fmt = (t) => {
|
|
const total = Math.max(0, t);
|
|
const m = Math.floor(total / 60);
|
|
const s = Math.floor(total % 60);
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
};
|
|
|
|
const numFont = '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
|
|
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
padding: '12px',
|
|
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%)',
|
|
width: '100%',
|
|
color: '#fff',
|
|
fontFamily: numFont,
|
|
userSelect: 'none',
|
|
flexShrink: 0,
|
|
boxSizing: 'border-box',
|
|
}}>
|
|
{/* Play / pause — bare white triangle, no button chrome */}
|
|
<IconButton onClick={onPlayPause} tooltip={playing ? '暂停' : '播放'}>
|
|
{playing ? (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M3.33333 1.33398C2.59695 1.33398 2 1.93094 2 2.66732V13.334C2 14.0704 2.59695 14.6673 3.33333 14.6673H4.66667C5.40305 14.6673 6 14.0704 6 13.334V2.66732C6 1.93094 5.40305 1.33398 4.66667 1.33398H3.33333Z" fill="currentColor"/>
|
|
<path d="M11.3333 1.33398C10.597 1.33398 10 1.93094 10 2.66732V13.334C10 14.0704 10.597 14.6673 11.3333 14.6673H12.6667C13.403 14.6673 14 14.0704 14 13.334V2.66732C14 1.93094 13.403 1.33398 12.6667 1.33398H11.3333Z" fill="currentColor"/>
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M14.0489 9.13127C14.873 8.60116 14.873 7.39754 14.0489 6.86743L4.74461 0.882617C3.84764 0.305661 2.66699 0.948902 2.66699 2.01454V13.9842C2.66699 15.0498 3.84764 15.693 4.74461 15.1161L14.0489 9.13127Z" fill="currentColor"/>
|
|
</svg>
|
|
)}
|
|
</IconButton>
|
|
|
|
{/* Current time */}
|
|
<div style={{
|
|
fontFamily: numFont,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
color: '#fff',
|
|
minWidth: 40,
|
|
textAlign: 'center'
|
|
}}>
|
|
{fmt(time)}
|
|
</div>
|
|
|
|
{/* Scrub track — white fill on translucent-white rail + draggable knob */}
|
|
<div
|
|
ref={trackRef}
|
|
onMouseMove={onTrackMove}
|
|
onMouseLeave={onTrackLeave}
|
|
onMouseDown={onTrackDown}
|
|
style={{
|
|
flex: 1,
|
|
height: 20,
|
|
position: 'relative',
|
|
cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center',
|
|
}}
|
|
>
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: 0, right: 0, height: 4,
|
|
background: 'rgba(255,255,255,0.6)',
|
|
borderRadius: 2,
|
|
}}/>
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: 0, width: `${pct}%`, height: 4,
|
|
background: 'rgba(255,255,255,0.9)',
|
|
borderRadius: 2,
|
|
}}/>
|
|
{/* Progress knob — outer div is an enlarged transparent hit area for easier grabbing */}
|
|
<div
|
|
onMouseDown={onBallDown}
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${pct}%`, top: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: 20, height: 20,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: dragging ? 'grabbing' : 'grab',
|
|
zIndex: 5,
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: dragging ? 14 : 12,
|
|
height: dragging ? 14 : 12,
|
|
background: '#fff',
|
|
borderRadius: '50%',
|
|
border: '0.5px solid #D2D5D8',
|
|
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
|
|
transition: 'width 100ms, height 100ms',
|
|
}}/>
|
|
</div>
|
|
{trackHover && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: trackHover.x,
|
|
bottom: '100%',
|
|
transform: 'translateX(-50%)',
|
|
marginBottom: 2,
|
|
pointerEvents: 'none',
|
|
zIndex: 10,
|
|
}}>
|
|
<TooltipBubble text={fmt(trackHover.t)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Duration — dimmed */}
|
|
<div style={{
|
|
fontFamily: numFont,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
color: 'rgba(255,255,255,0.6)',
|
|
minWidth: 40,
|
|
textAlign: 'center',
|
|
}}>
|
|
{fmt(duration)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function IconButton({ children, onClick, tooltip }) {
|
|
const [hover, setHover] = React.useState(false);
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
aria-label={tooltip}
|
|
onMouseEnter={() => setHover(true)}
|
|
onMouseLeave={() => setHover(false)}
|
|
style={{
|
|
position: 'relative',
|
|
width: 24, height: 24,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: hover ? 'rgba(255,255,255,0.1)' : 'transparent',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
color: '#fff',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
transition: 'background 120ms',
|
|
}}
|
|
>
|
|
{children}
|
|
{tooltip && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: '100%',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
marginBottom: 8,
|
|
pointerEvents: 'none',
|
|
opacity: hover ? 1 : 0,
|
|
transition: 'opacity 120ms',
|
|
zIndex: 10,
|
|
}}>
|
|
<TooltipBubble text={tooltip} />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Tooltip bubble ────────────────────────────────────────────────────────────
|
|
// Dark rounded bubble with a downward tail. Positioning is up to the caller.
|
|
function TooltipBubble({ text }) {
|
|
return (
|
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
|
<div style={{
|
|
background: '#1F2329',
|
|
color: '#fff',
|
|
fontSize: 12,
|
|
lineHeight: '16px',
|
|
padding: '6px 12px',
|
|
borderRadius: 6,
|
|
whiteSpace: 'nowrap',
|
|
fontFamily: '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
|
|
fontVariantNumeric: 'tabular-nums',
|
|
boxShadow: '0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)',
|
|
}}>
|
|
{text}
|
|
</div>
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%) rotate(45deg)',
|
|
width: 9, height: 9,
|
|
borderRadius: '0 0 3px 0',
|
|
background: '#1F2329',
|
|
}}/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
Object.assign(window, {
|
|
Easing, interpolate, animate, clamp,
|
|
TimelineContext, useTime, useTimeline,
|
|
Sprite, SpriteContext, useSprite,
|
|
TextSprite, ImageSprite, RectSprite,
|
|
Stage, PlaybackBar,
|
|
});
|
|
|