refactor audio to hook

This commit is contained in:
sk1982 2024-03-17 20:39:12 -04:00
parent a41df38a28
commit 2f24f53311
5 changed files with 117 additions and 64 deletions

33
package-lock.json generated
View File

@ -46,6 +46,7 @@
"kysely-codegen": "^0.13.1", "kysely-codegen": "^0.13.1",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"type-fest": "^4.12.0",
"typescript": "^5" "typescript": "^5"
} }
}, },
@ -4267,6 +4268,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/boxen/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/boxen/node_modules/wrap-ansi": { "node_modules/boxen/node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -6811,6 +6824,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/globals/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": { "node_modules/globalthis": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
@ -10967,12 +10992,12 @@
} }
}, },
"node_modules/type-fest": { "node_modules/type-fest": {
"version": "0.20.2", "version": "4.12.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.12.0.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "integrity": "sha512-5Y2/pp2wtJk8o08G0CMkuFPCO354FGwk/vbidxrdhRGZfd0tFnb4Qb8anp9XxXriwBgVPjdWbKpGl4J9lJY2jQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=10" "node": ">=16"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"

View File

@ -52,6 +52,7 @@
"kysely-codegen": "^0.13.1", "kysely-codegen": "^0.13.1",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"type-fest": "^4.12.0",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -12,6 +12,8 @@ import { ChuniAvatar } from '@/components/chuni/avatar';
import { CHUNI_VOICE_LINES } from '@/helpers/chuni/voice'; import { CHUNI_VOICE_LINES } from '@/helpers/chuni/voice';
import { PlayIcon, StopIcon } from '@heroicons/react/24/solid'; import { PlayIcon, StopIcon } from '@heroicons/react/24/solid';
import { SaveIcon } from '@/components/save-icon'; import { SaveIcon } from '@/components/save-icon';
import { useAudio } from '@/helpers/use-audio';
import { useIsMounted } from 'usehooks-ts';
export type ChuniUserboxProps = { export type ChuniUserboxProps = {
profile: ChuniUserData, profile: ChuniUserData,
@ -44,12 +46,20 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
.find(i => ('id' in i ? i.id : i.avatarAccessoryId) === profile?.[profileKey])])) as EquippedItem); .find(i => ('id' in i ? i.id : i.avatarAccessoryId) === profile?.[profileKey])])) as EquippedItem);
const [equipped, setEquipped] = useState<EquippedItem>(initialEquipped.current); const [equipped, setEquipped] = useState<EquippedItem>(initialEquipped.current);
const [saved, setSaved] = useState<SavedItem>(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any); const [saved, setSaved] = useState<SavedItem>(Object.fromEntries(Object.keys(ITEM_KEYS).map(k => [k, true])) as any);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playingVoice, setPlayingVoice] = useState(false); const [playingVoice, setPlayingVoice] = useState(false);
const [selectedLine, setSelectedLine] = useState(new Set(['0035'])); const [selectedLine, setSelectedLine] = useState(new Set(['0035']));
const [playPreviews, setPlayPreviews] = useState(true); const [playPreviews, _setPlayPreviews] = useState(true);
const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null); const [selectingVoice, setSelectingVoice] = useState<EquippedItem['systemVoice'] | null>(null);
const setPlayPreviews = (play: boolean) => {
_setPlayPreviews(play);
localStorage.setItem('chuni-userbox-play-previews', play ? '1' : '');
}
useEffect(() => {
_setPlayPreviews(!!(localStorage.getItem('chuni-userbox-play-previews') ?? 1));
}, []);
const equipItem = <K extends keyof RequiredUserbox>(k: K, item: RequiredUserbox[K][number] | undefined | null) => { const equipItem = <K extends keyof RequiredUserbox>(k: K, item: RequiredUserbox[K][number] | undefined | null) => {
if (!item || equipped[k] === item) return; if (!item || equipped[k] === item) return;
@ -63,28 +73,11 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
.entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) })) .entries(initialEquipped.current).filter(([k]) => items.includes(k as any))) }))
}; };
useEffect(() => { const audioRef = useAudio({
const audio = audioRef.current; play: () => setPlayingVoice(true),
if (!audio) return; ended: () => setPlayingVoice(false),
audio.volume = 0.25; pause: () => setPlayingVoice(false)
}, audio => audio.volume = 0.25);
const setPlay = () => {
setPlayingVoice(true);
};
const setStop = () => {
setPlayingVoice(false);
};
audio.addEventListener('play', setPlay);
audio.addEventListener('ended', setStop);
audio.addEventListener('pause', setStop)
return () => {
audio.removeEventListener('play', setPlay);
audio.removeEventListener('ended', setStop);
audio.removeEventListener('pause', setStop);
audio.pause();
};
}, []);
const play = (src: string) => { const play = (src: string) => {
if (!audioRef.current || !playPreviews) return; if (!audioRef.current || !playPreviews) return;
@ -202,8 +195,6 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
{/* begin system voice */} {/* begin system voice */}
<div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center"> <div className="flex flex-col p-4 w-full col-span-full xl:col-span-6 sm:bg-content2 rounded-lg sm:shadow-inner items-center">
<audio ref={audioRef} />
<div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10"> <div className="text-2xl font-semibold mb-4 px-2 flex w-full h-10">
Voice Voice

View File

@ -2,7 +2,8 @@
import { Button, Card, CardBody, Slider } from '@nextui-org/react'; import { Button, Card, CardBody, Slider } from '@nextui-org/react';
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/react/24/solid'; import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/react/24/solid';
import { ReactNode, useEffect, useRef, useState } from 'react'; import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useAudio } from '@/helpers/use-audio';
export type MusicPlayerProps = { export type MusicPlayerProps = {
audio: string, audio: string,
@ -22,42 +23,24 @@ export const MusicPlayer = ({ audio, image, children, className }: MusicPlayerPr
const [duration, setDuration] = useState(NaN); const [duration, setDuration] = useState(NaN);
const [progress, setProgress] = useState(NaN); const [progress, setProgress] = useState(NaN);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const audioRef = useAudio(audio, {
loadedmetadata() {
if (this.duration !== undefined)
setDuration(this.duration);
setProgress(0);
},
timeupdate() {
if (this.currentTime !== undefined)
setProgress(this.currentTime);
},
ended: () => setPlaying(false)
}, audio => {
if (!Number.isNaN(audio.duration)) { if (!Number.isNaN(audio.duration)) {
setDuration(audio.duration) setDuration(audio.duration)
setProgress(0); setProgress(0);
} }
});
const metadata = () => {
if (audio.duration !== undefined)
setDuration(audio.duration);
setProgress(0);
};
audio.addEventListener('loadedmetadata', metadata);
const timeupdate = () => {
if (audio.currentTime !== undefined)
setProgress(audio.currentTime);
};
audio.addEventListener('timeupdate', timeupdate);
const ended = () => {
setPlaying(false);
}
audio.addEventListener('ended', ended);
return () => {
audio.removeEventListener('loadedmetadata', metadata);
audio.removeEventListener('timeupdate', timeupdate);
audio.removeEventListener('ended', ended);
audio.pause();
};
}, []);
useEffect(() => { useEffect(() => {
if (playing) if (playing)
@ -70,8 +53,6 @@ export const MusicPlayer = ({ audio, image, children, className }: MusicPlayerPr
return (<Card isBlurred radius="none" className={`border-none shadow-lg sm:rounded-2xl w-full max-w-full sm:max-w-[48rem] ${className ?? ''}`}> return (<Card isBlurred radius="none" className={`border-none shadow-lg sm:rounded-2xl w-full max-w-full sm:max-w-[48rem] ${className ?? ''}`}>
<CardBody className="sm:rounded-2xl sm:p-4 bg-content1 sm:bg-content2"> <CardBody className="sm:rounded-2xl sm:p-4 bg-content1 sm:bg-content2">
<audio src={audio} ref={audioRef} />
<div className="grid grid-cols-12"> <div className="grid grid-cols-12">
<div className="col-span-full sm:col-span-4 h-full flex items-center justify-center sm:justify-start"> <div className="col-span-full sm:col-span-4 h-full flex items-center justify-center sm:justify-start">
<img src={image} alt="" className="aspect-square rounded-md shadow-2xl max-w-56 w-full border border-gray-500 sm:border-0" /> <img src={image} alt="" className="aspect-square rounded-md shadow-2xl max-w-56 w-full border border-gray-500 sm:border-0" />

55
src/helpers/use-audio.ts Normal file
View File

@ -0,0 +1,55 @@
'use client';
import { RefObject, useCallback, useEffect, useRef } from 'react';
type UseAudioEvents = Partial<{
readonly [K in keyof HTMLMediaElementEventMap]: ((this: HTMLAudioElement, ev: HTMLMediaElementEventMap[K]) => any) |
[(this: HTMLAudioElement, ev: HTMLMediaElementEventMap[K]) => any,
options?: boolean | AddEventListenerOptions]
}>;
type UseAudioCallback = (audio: HTMLAudioElement) => any;
type UseAudio = {
(src: string, events: UseAudioEvents, cb?: UseAudioCallback): RefObject<HTMLAudioElement | null>,
(events: UseAudioEvents, cb?: UseAudioCallback): RefObject<HTMLAudioElement | null>
};
export const useAudio: UseAudio = (srcOrEvents, eventsOrCb, cb?) => {
const src = typeof srcOrEvents === 'string' ? srcOrEvents : undefined;
const events = typeof eventsOrCb === 'object' ? eventsOrCb : (srcOrEvents as UseAudioEvents);
const callback = useCallback(typeof cb === 'function' ? cb : eventsOrCb as UseAudioCallback, []);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (!audioRef.current)
audioRef.current = new Audio(src);
const audio = audioRef.current;
if (audio.src !== src && src) audio.src = src;
Object.entries(events).forEach(([name, listener]) => {
if (typeof listener === 'function')
audio.addEventListener(name, listener as any);
else
audio.addEventListener(name, ...(listener as [any, any]));
});
return () => {
Object.entries(events).forEach(([name, listener]) => {
if (typeof listener === 'function')
audio.removeEventListener(name, listener as any);
else
audio.removeEventListener(name, ...(listener as [any, any]));
});
audio.pause();
}
}, [src]);
useEffect(() => {
if (callback && audioRef.current) callback(audioRef.current);
}, [callback]);
return audioRef;
};