feat:移除了弹窗,服务器添加sls
This commit is contained in:
@ -0,0 +1,773 @@
|
||||
/* 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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user