From c5528e49ad4e180734cb91289805c21e43b14b13 Mon Sep 17 00:00:00 2001 From: AEPKILL Date: Wed, 4 Aug 2021 11:28:05 +0800 Subject: [PATCH] feat: add sound play animation (#125) * feat: add sound animation * fix: press any key will be start * fix: play multiple word sound * fix: should only add howl listener * fix: can trigger overlapping sounds manually * fix: blocked attempt to create a WebMediaPlayer - [Intervention] Blocked attempt to create a WebMediaPlayer as there are too many WebMediaPlayers already in existence. - See crbug.com/1144736#c27 * revert: revert word sound setting * refactor: give up using "useImperativeHandle" * feat: add word pronunciation hot key tips * feat: modify the hover content Co-authored-by: zhangkaiyi004 --- package.json | 4 +- src/components/SoundIcon/index.tsx | 48 +++++++++++++++ .../volume-icons/VolumeHieghIcon.tsx | 12 ++++ .../SoundIcon/volume-icons/VolumeIcon.tsx | 11 ++++ .../SoundIcon/volume-icons/VolumeLowIcon.tsx | 11 ++++ .../volume-icons/VolumeMediumIcon.tsx | 11 ++++ src/components/Tooltip/index.tsx | 20 ++++--- src/components/Word/index.module.css | 18 ++++++ src/components/Word/index.tsx | 58 +++++++++--------- src/components/WordSound/index.tsx | 48 +++++++++++++++ src/hooks/usePronunciation.ts | 59 +++++++++++-------- src/pages/Typing/Switcher/index.tsx | 2 +- src/pages/Typing/index.tsx | 34 +++++------ src/utils/utils.ts | 20 +++++++ yarn.lock | 18 ++++-- 15 files changed, 289 insertions(+), 85 deletions(-) create mode 100644 src/components/SoundIcon/index.tsx create mode 100644 src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx create mode 100644 src/components/SoundIcon/volume-icons/VolumeIcon.tsx create mode 100644 src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx create mode 100644 src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx create mode 100644 src/components/WordSound/index.tsx diff --git a/package.json b/package.json index f30bdb47..50118ef8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@types/howler": "^2.2.3", "@types/jest": "^26.0.15", "@types/lodash": "^4.14.168", "@types/node": "^12.0.0", @@ -26,6 +27,7 @@ "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", + "howler": "^2.2.3", "husky": "^4.3.8", "lint-staged": "^10.5.3", "lodash": "^4.17.20", @@ -42,7 +44,7 @@ "source-map-explorer": "^2.5.2", "swr": "^0.4.1", "typescript": "^4.0.3", - "use-sound": "^2.0.1", + "use-sound": "^4.0.1", "web-vitals": "^0.2.4" }, "scripts": { diff --git a/src/components/SoundIcon/index.tsx b/src/components/SoundIcon/index.tsx new file mode 100644 index 00000000..4a9056a9 --- /dev/null +++ b/src/components/SoundIcon/index.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useState } from 'react' +import VolumeIcon from './volume-icons/VolumeIcon' +import VolumeLowIcon from './volume-icons/VolumeLowIcon' +import VolumeMediumIcon from './volume-icons/VolumeMediumIcon' +import VolumeHighIcon from './volume-icons/VolumeHieghIcon' + +const volumeIcons = [VolumeIcon, VolumeLowIcon, VolumeMediumIcon, VolumeHighIcon] + +export const SoundIcon = React.memo(({ duration = 500, animated = false, ...rest }, ref) => { + const [animationFrameIndex, setAnimationFrameIndex] = useState(0) + + useEffect(() => { + if (animated) { + const timer = window.setTimeout(() => { + const index = animationFrameIndex < volumeIcons.length - 1 ? animationFrameIndex + 1 : 0 + setAnimationFrameIndex(index) + }, duration) + return () => { + clearTimeout(timer) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animated, animationFrameIndex]) + + useEffect(() => { + if (!animated) { + const timer = setTimeout(() => { + setAnimationFrameIndex(0) + }, duration) + return () => clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animated]) + + const Icon = volumeIcons[animationFrameIndex] + + return +}) + +export type SoundIconProps = { + animated?: boolean + duration?: number +} & Omit, 'ref'> + +export type SoundIconRef = { + playAnimation(): void + stopAnimation(): void +} diff --git a/src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx b/src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx new file mode 100644 index 00000000..aaccabb6 --- /dev/null +++ b/src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function VolumeHighIcon(props: React.SVGProps) { + return ( + + + + + ) +} + +export default VolumeHighIcon diff --git a/src/components/SoundIcon/volume-icons/VolumeIcon.tsx b/src/components/SoundIcon/volume-icons/VolumeIcon.tsx new file mode 100644 index 00000000..36615c66 --- /dev/null +++ b/src/components/SoundIcon/volume-icons/VolumeIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function VolumeIcon(props: React.SVGProps) { + return ( + + + + ) +} + +export default VolumeIcon diff --git a/src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx b/src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx new file mode 100644 index 00000000..fa032d60 --- /dev/null +++ b/src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function VolumeLowIcon(props: React.SVGProps) { + return ( + + + + ) +} + +export default VolumeLowIcon diff --git a/src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx b/src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx new file mode 100644 index 00000000..cb8e7f78 --- /dev/null +++ b/src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function VolumeMediumIcon(props: React.SVGProps) { + return ( + + + + ) +} + +export default VolumeMediumIcon diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 5d8f3b3e..273371e8 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -1,13 +1,7 @@ import { useState } from 'react' +import { classNames } from '../../utils/utils' -const Tooltip: React.FC< - React.PropsWithChildren<{ - /** 显示文本 */ - content: string - /** 位置 */ - placement?: 'top' | 'bottom' - }> -> = ({ children, content, placement = 'top' }) => { +const Tooltip: React.FC> = ({ children, content, className, placement = 'top' }) => { const [visible, setVisible] = useState(false) const placementClasses = { @@ -16,7 +10,7 @@ const Tooltip: React.FC< }[placement] return ( -
+
setVisible(true)} onMouseLeave={() => setVisible(false)}> {children}
@@ -33,4 +27,12 @@ const Tooltip: React.FC< ) } +export type TooltipProps = { + /** 显示文本 */ + content: string + /** 位置 */ + placement?: 'top' | 'bottom' + className?: string +} + export default Tooltip diff --git a/src/components/Word/index.module.css b/src/components/Word/index.module.css index 61d176c8..d2858391 100644 --- a/src/components/Word/index.module.css +++ b/src/components/Word/index.module.css @@ -24,3 +24,21 @@ transform: translate3d(4px, 0, 0); } } + +.word-sound { + position: absolute; + width: 40px; + height: 40px; + transform: translateY(calc(-50% - 23px)); + right: -55px; + cursor: pointer; + fill: theme('colors.gray.600'); +} +.word-sound .prefix__icon { + width: 40px; + height: 40px; +} +.dark .word-sound { + fill: theme('colors.gray.50'); + opacity: 0.8; +} diff --git a/src/components/Word/index.tsx b/src/components/Word/index.tsx index 44a98e99..e7398497 100644 --- a/src/components/Word/index.tsx +++ b/src/components/Word/index.tsx @@ -3,13 +3,13 @@ import Letter, { LetterState } from './Letter' import { isLegal, isChineseSymbol } from '../../utils/utils' import useSounds from 'hooks/useSounds' import style from './index.module.css' -import usePronunciationSound from 'hooks/usePronunciation' +import WordSound from 'components/WordSound' +import { useAppState } from '../../store/AppState' const EXPLICIT_SPACE = '␣' const Word: React.FC = ({ word = 'defaultWord', onFinish, isStart, wordVisible = true }) => { - // Used in `usePronunciationSound`. - const originalWord = word + const originWord = word word = word.replace(new RegExp(' ', 'g'), EXPLICIT_SPACE) word = word.replace(new RegExp('…', 'g'), '..') @@ -19,7 +19,7 @@ const Word: React.FC = ({ word = 'defaultWord', onFinish, isStart, wo const [isFinish, setIsFinish] = useState(false) const [hasWrong, setHasWrong] = useState(false) const [playKeySound, playBeepSound, playHintSound] = useSounds() - const playPronounce = usePronunciationSound(originalWord) + const { pronunciation } = useAppState() const onKeydown = useCallback( (e) => { @@ -61,21 +61,17 @@ const Word: React.FC = ({ word = 'defaultWord', onFinish, isStart, wo useEffect(() => { if (hasWrong) { playBeepSound() - setTimeout(() => { + const timer = setTimeout(() => { setInputWord('') setHasWrong(false) }, 300) + + return () => { + clearTimeout(timer) + } } }, [hasWrong, isFinish, playBeepSound]) - useEffect(() => { - if (isStart && inputWord.length === 0) { - playPronounce() - } - // SAFETY: Don't depend on `playPronounce`! It will cost audio play again and again. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isStart, word, inputWord]) - useLayoutEffect(() => { let hasWrong = false, wordLength = word.length, @@ -99,19 +95,26 @@ const Word: React.FC = ({ word = 'defaultWord', onFinish, isStart, wo setStatesList(statesList) }, [inputWord, word]) + const playWordSound = pronunciation !== false + return ( -
- {/* {console.log(inputWord, word)} */} - {word.split('').map((t, index) => { - return ( - - ) - })} +
+
+
+ {/* {console.log(inputWord, word)} */} + {word.split('').map((t, index) => { + return ( + + ) + })} +
+ {playWordSound && } +
) } @@ -123,8 +126,3 @@ export type WordProps = { wordVisible: boolean } export default Word - -/** - * Warning: Can't perform a React state update on an unmounted component. - * 为 use-sound 未解决的 bug - */ diff --git a/src/components/WordSound/index.tsx b/src/components/WordSound/index.tsx new file mode 100644 index 00000000..5abde96b --- /dev/null +++ b/src/components/WordSound/index.tsx @@ -0,0 +1,48 @@ +import { SoundIcon, SoundIconProps } from 'components/SoundIcon' +import Tooltip from 'components/Tooltip' +import usePronunciationSound from 'hooks/usePronunciation' +import React, { useEffect, useCallback } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' + +const WordSound: React.FC = React.memo(({ word, className, inputWord, ...rest }) => { + const { play, stop, isPlaying } = usePronunciationSound(word) + + useHotkeys( + 'ctrl+j', + (e) => { + e.preventDefault() + stop() + play() + }, + [play, stop], + ) + + useEffect(() => { + if (inputWord.length === 0) { + stop() + play() + } + }, [play, inputWord, stop]) + + useEffect(() => { + return stop + }, [word, stop]) + + const handleClickSoundIcon = useCallback(() => { + stop() + play() + }, [play, stop]) + + return ( + + + + ) +}) + +export type WordSoundProps = { + word: string + inputWord: string +} & SoundIconProps + +export default WordSound diff --git a/src/hooks/usePronunciation.ts b/src/hooks/usePronunciation.ts index 1c73ef26..3a5696d0 100644 --- a/src/hooks/usePronunciation.ts +++ b/src/hooks/usePronunciation.ts @@ -1,31 +1,44 @@ -import { useAppState } from 'store/AppState' -import { noop } from 'lodash' +import { Howl } from 'howler' import { useEffect, useState } from 'react' - -declare type PronounceFunction = () => void +import { PronunciationType, useAppState } from 'store/AppState' +import useSound from 'use-sound' +import { HookOptions } from 'use-sound/dist/types' +import { addHowlListener } from '../utils/utils' const pronunciationApi = 'https://dict.youdao.com/dictvoice?audio=' - -export default function usePronunciationSound(word: string): PronounceFunction { - const [audio, setAudio] = useState(null) - const { pronunciation } = useAppState() - const ukPronounceFunction = () => setAudio(new Audio(pronunciationApi + word + '&type=1')) - const usPronounceFunction = () => setAudio(new Audio(pronunciationApi + word + '&type=2')) - - useEffect(() => { - audio?.play() - return () => { - // Pause the audio when unMount - audio?.pause() - } - }, [audio, word]) - +function generateWordSoundSrc(word: string, pronunciation: Exclude) { switch (pronunciation) { - case false: - return noop case 'uk': - return ukPronounceFunction + return `${pronunciationApi}${word}&type=1` case 'us': - return usPronounceFunction + return `${pronunciationApi}${word}&type=2` } } + +export default function usePronunciationSound(word: string) { + const { pronunciation } = useAppState() + const [isPlaying, setIsPlaying] = useState(false) + + const [play, { stop, sound }] = useSound(generateWordSoundSrc(word, pronunciation as Exclude), { + html5: true, + format: ['mp3'], + } as HookOptions) + + useEffect(() => { + if (!sound) return + + const unListens: Array<() => void> = [] + + unListens.push(addHowlListener(sound, 'play', () => setIsPlaying(true))) + unListens.push(addHowlListener(sound, 'end', () => setIsPlaying(false))) + unListens.push(addHowlListener(sound, 'pause', () => setIsPlaying(false))) + unListens.push(addHowlListener(sound, 'playerror', () => setIsPlaying(false))) + + return () => { + unListens.forEach((unListen) => unListen()) + ;(sound as Howl).unload() + } + }, [sound]) + + return { play, stop, isPlaying } +} diff --git a/src/pages/Typing/Switcher/index.tsx b/src/pages/Typing/Switcher/index.tsx index b5f858eb..b934b2d4 100644 --- a/src/pages/Typing/Switcher/index.tsx +++ b/src/pages/Typing/Switcher/index.tsx @@ -45,7 +45,7 @@ const Switcher: React.FC = ({ state, dispatch }) => { return (
- +