147 lines
4.1 KiB
TypeScript
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; |