Files
voicepaste/web/src/components/MicButton.tsx

123 lines
4.0 KiB
TypeScript

import { useCallback } from "react";
import { useAppStore } from "../stores/app-store";
interface MicButtonProps {
onStart: () => void;
onStop: () => void;
}
export function MicButton({ onStart, onStop }: MicButtonProps) {
const connected = useAppStore((s) => s.connectionStatus === "connected");
const micReady = useAppStore((s) => s.micReady);
const recording = useAppStore((s) => s.recording);
const pendingStart = useAppStore((s) => s.pendingStart);
const stopping = useAppStore((s) => s.stopping);
const weakNetwork = useAppStore((s) => s.weakNetwork);
const isActive = recording || pendingStart || stopping;
const disabled = !connected || !micReady || stopping;
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
onStart();
},
[onStart],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.releasePointerCapture(e.pointerId);
onStop();
},
[onStop],
);
// Read latest state to avoid stale closures
const handlePointerLeave = useCallback(() => {
const s = useAppStore.getState();
if (s.recording || s.pendingStart) onStop();
}, [onStop]);
const handlePointerCancel = useCallback(() => {
const s = useAppStore.getState();
if (s.recording || s.pendingStart) onStop();
}, [onStop]);
const disabledClasses =
"cursor-not-allowed border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary opacity-30 shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
const activeClasses =
"animate-mic-breathe scale-[1.06] border-accent-hover bg-accent text-white shadow-[0_0_32px_rgba(99,102,241,0.35),0_0_80px_rgba(99,102,241,0.2)]";
const weakClasses =
"border-amber-400 bg-linear-to-br from-amber-400/15 to-surface text-amber-200 shadow-[0_0_22px_rgba(251,191,36,0.28)]";
const idleClasses =
"border-edge bg-linear-to-br from-surface-hover to-surface text-fg-secondary shadow-[0_2px_12px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.04)]";
return (
<section className="flex shrink-0 flex-col items-center gap-3.5 pt-5 pb-4">
<div className="relative flex touch-none items-center justify-center">
<button
type="button"
disabled={disabled}
className={`relative z-1 flex size-24 cursor-pointer touch-none select-none items-center justify-center rounded-full border-2 transition-all duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)] ${
disabled
? disabledClasses
: weakNetwork
? weakClasses
: isActive
? activeClasses
: idleClasses
}`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
>
<svg
viewBox="0 0 24 24"
width={48}
height={48}
fill="currentColor"
aria-label="麦克风"
role="img"
>
<title>{"麦克风"}</title>
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</button>
{/* Wave rings */}
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{[0, 0.8, 1.6].map((delay) => (
<span
key={delay}
className={`absolute inset-0 rounded-full border-[1.5px] border-accent ${
isActive ? "animate-ring-expand" : "opacity-0"
}`}
style={isActive ? { animationDelay: `${delay}s` } : undefined}
/>
))}
</div>
</div>
<p className="font-medium text-fg-dim text-sm">
{!micReady
? "请先准备麦克风"
: !connected
? "连接中断,等待重连"
: stopping
? "收尾中…"
: weakNetwork
? "网络波动,已启用缓冲"
: "按住说话"}
</p>
</section>
);
}