nonszy-space/src/components/wobbling-image.tsx
2025-10-07 16:06:10 +07:00

147 lines
4.1 KiB
TypeScript

'use client'
import clsx from "clsx";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react"
interface WobblingImageInterface {
images: {
idle: string;
poked?: string;
weird?: string;
}
}
function WobblingImage ({
images
}: WobblingImageInterface) {
const size = 400;
const [isPoked, setPoked] = useState(false);
const pokeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const audioPath = "/sound/poke.wav";
const audioContextRef = useRef<AudioContext | null>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioLoaded, setAudioLoaded] = useState(false);
const [dummyAudio, setDummyAudio] = useState<HTMLAudioElement | null>(null);
useEffect(() => {
if (typeof Audio !== undefined) setDummyAudio(new Audio(audioPath))
return () => {
if (dummyAudio) dummyAudio.pause();
}
}, [])
const initializeAudioContext = useCallback((): AudioContext => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return audioContextRef.current;
}, []);
const loadAudio = useCallback(async (url: string): Promise<AudioBuffer> => {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const context = initializeAudioContext();
return await context.decodeAudioData(arrayBuffer);
}, [initializeAudioContext]);
const initializeAudio = useCallback(async (): Promise<void> => {
if (audioBuffer) return;
setAudioLoaded(false);
try {
const buffer = await loadAudio(audioPath);
setAudioBuffer(buffer);
} catch (error) {
console.error('Failed to load audio:', error);
}
finally {
setAudioLoaded(true);
}
}, [audioBuffer, loadAudio]);
const playRandomPitch = useCallback(async (): Promise<void> => {
await initializeAudio();
if (!audioBuffer || !audioContextRef.current) return;
const context = audioContextRef.current;
const changeToLowPitch = Math.floor(Math.random() * 10);
const randomPitch = Math.random() * 0.2 + (changeToLowPitch == 1 ? 0.25 : 0.85);
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.playbackRate.value = randomPitch;
source.connect(context.destination);
source.start(0);
source.onended = () => {
source.disconnect();
};
}, [audioBuffer, initializeAudio]);
function handleClick() {
setIsAnimating(false);
requestAnimationFrame(() => setIsAnimating(true));
playRandomPitch();
if (dummyAudio && !audioLoaded) {
dummyAudio.currentTime = 0;
dummyAudio.play();
}
if (pokeTimeoutRef.current) {
clearTimeout(pokeTimeoutRef.current);
}
setPoked(true);
pokeTimeoutRef.current = setTimeout(() => {
setPoked(false);
}, 600);
}
function handleAnimationEnd() {
setIsAnimating(false);
}
return (
<div className="">
<div
className={clsx(
"relative mx-auto cursor-grab h-[400px] select-none",
isAnimating && "animate-[click-bounce_250ms_ease-out]"
)}
onClick={handleClick}
onAnimationEnd={handleAnimationEnd}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}
onMouseDown={(e) => e.preventDefault()}
onPointerDown={(e) => e.preventDefault()}
>
<Image
className={clsx("absolute left-1/2 -translate-x-1/2 top-0 pointer-events-none", isPoked ? "opacity-0" : "opacity-100")}
alt="clip1"
src={images.idle}
width={size}
height={size}
draggable={false}
/>
<Image
className={clsx("absolute left-1/2 -translate-x-1/2 top-0 pointer-events-none", isPoked ? "opacity-100" : "opacity-0")}
alt="clip2"
src={images.poked || images.idle}
width={size}
height={size}
draggable={false}
/>
</div>
</div>
)
}
export default WobblingImage;