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 <zhangkaiyi004@ke.com>
This commit is contained in:
AEPKILL
2021-08-04 11:28:05 +08:00
committed by GitHub
parent b9da985f3e
commit c5528e49ad
15 changed files with 289 additions and 85 deletions

View File

@@ -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": {

View File

@@ -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<SoundIconProps>(({ 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 <Icon {...rest} />
})
export type SoundIconProps = {
animated?: boolean
duration?: number
} & Omit<React.SVGProps<SVGSVGElement>, 'ref'>
export type SoundIconRef = {
playAnimation(): void
stopAnimation(): void
}

View File

@@ -0,0 +1,12 @@
import React from 'react'
function VolumeHighIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M699.034 761.907a25.6 25.6 0 01-16.18-45.414C736.973 672.512 768 607.283 768 537.65s-31.027-134.81-85.094-178.841a25.549 25.549 0 1132.307-39.68C781.312 372.89 819.2 452.557 819.2 537.702s-37.888 164.762-103.987 218.573a25.6 25.6 0 01-16.128 5.735z" />
<path d="M795.904 881.1a25.6 25.6 0 01-16.18-45.414C869.889 762.368 921.6 653.722 921.6 537.651s-51.712-224.717-141.875-298.035a25.549 25.549 0 1132.307-39.68C914.176 283.034 972.8 406.17 972.8 537.702s-58.573 254.67-160.768 337.767a25.6 25.6 0 01-16.128 5.734zm-193.69-238.438a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
</svg>
)
}
export default VolumeHighIcon

View File

@@ -0,0 +1,11 @@
import React from 'react'
function VolumeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
</svg>
)
}
export default VolumeIcon

View File

@@ -0,0 +1,11 @@
import React from 'react'
function VolumeLowIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M602.214 642.662a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
</svg>
)
}
export default VolumeLowIcon

View File

@@ -0,0 +1,11 @@
import React from 'react'
function VolumeMediumIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M699.034 761.907a25.6 25.6 0 01-16.18-45.414C736.973 672.512 768 607.283 768 537.65s-31.027-134.81-85.094-178.841a25.549 25.549 0 1132.307-39.68C781.312 372.89 819.2 452.557 819.2 537.702s-37.888 164.762-103.987 218.573a25.6 25.6 0 01-16.128 5.735zm-96.82-119.245a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
</svg>
)
}
export default VolumeMediumIcon

View File

@@ -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<React.PropsWithChildren<TooltipProps>> = ({ children, content, className, placement = 'top' }) => {
const [visible, setVisible] = useState(false)
const placementClasses = {
@@ -16,7 +10,7 @@ const Tooltip: React.FC<
}[placement]
return (
<div className="relative">
<div className={classNames('relative', className)}>
<div onMouseEnter={() => setVisible(true)} onMouseLeave={() => setVisible(false)}>
{children}
</div>
@@ -33,4 +27,12 @@ const Tooltip: React.FC<
)
}
export type TooltipProps = {
/** 显示文本 */
content: string
/** 位置 */
placement?: 'top' | 'bottom'
className?: string
}
export default Tooltip

View File

@@ -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;
}

View File

@@ -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<WordProps> = ({ 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<WordProps> = ({ 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<WordProps> = ({ 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<WordProps> = ({ word = 'defaultWord', onFinish, isStart, wo
setStatesList(statesList)
}, [inputWord, word])
const playWordSound = pronunciation !== false
return (
<div className={`pt-4 pb-1 flex items-center justify-center ${hasWrong ? style.wrong : ''}`}>
{/* {console.log(inputWord, word)} */}
{word.split('').map((t, index) => {
return (
<Letter
key={`${index}-${t}`}
visible={statesList[index] === 'correct' ? true : wordVisible}
letter={t}
state={statesList[index]}
/>
)
})}
<div className="flex justify-center pt-4 pb-1">
<div className="relative">
<div className={`flex items-center justify-center ${hasWrong ? style.wrong : ''}`}>
{/* {console.log(inputWord, word)} */}
{word.split('').map((t, index) => {
return (
<Letter
key={`${index}-${t}`}
visible={statesList[index] === 'correct' ? true : wordVisible}
letter={t}
state={statesList[index]}
/>
)
})}
</div>
{playWordSound && <WordSound word={originWord} inputWord={inputWord} className={`${style['word-sound']}`} />}
</div>
</div>
)
}
@@ -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
*/

View File

@@ -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<WordSoundProps> = 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 (
<Tooltip content="朗读发音Ctrl + J" className={className}>
<SoundIcon animated={isPlaying} {...rest} onClick={handleClickSoundIcon} />
</Tooltip>
)
})
export type WordSoundProps = {
word: string
inputWord: string
} & SoundIconProps
export default WordSound

View File

@@ -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<HTMLAudioElement | null>(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<PronunciationType, false>) {
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<PronunciationType, false>), {
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 }
}

View File

@@ -45,7 +45,7 @@ const Switcher: React.FC<SwitcherPropsType> = ({ state, dispatch }) => {
return (
<div className="flex items-center justify-center space-x-3">
<Tooltip content="开关声音Ctrl + M">
<Tooltip content="开关键盘声音Ctrl + M">
<button
className={`${state.sound ? 'text-indigo-400' : 'text-gray-400'} text-lg focus:outline-none`}
onClick={(e) => {

View File

@@ -73,12 +73,9 @@ const App: React.FC = () => {
if (isLegal(e.key) && !e.altKey && !e.ctrlKey && !e.metaKey) {
if (isStart) {
setInputCount((count) => count + 1)
} else {
if (document.activeElement?.nodeName === 'BODY') {
setIsStart(true)
}
}
}
setIsStart(true)
}
const onBlur = () => {
if (isStart) {
@@ -195,19 +192,22 @@ const App: React.FC = () => {
<Main>
<div className="container h-full relative flex mx-auto flex-col items-center">
<div className="h-1/3"></div>
<div>
<Word
key={`word-${wordList.words[order].name}-${order}`}
word={wordList.words[order].name}
onFinish={onFinish}
isStart={isStart}
wordVisible={switcherState.wordVisible}
/>
{switcherState.phonetic && (wordList.words[order].usphone || wordList.words[order].ukphone) && (
<Phonetic usphone={wordList.words[order].usphone} ukphone={wordList.words[order].ukphone} />
)}
<Translation key={`trans-${wordList.words[order].name}`} trans={wordList.words[order].trans.join('')} />
</div>
{!isStart && <h3 className="pb-4 text-xl text-gray-600 dark:text-gray-50 animate-pulse"></h3>}
{isStart && (
<div>
<Word
key={`word-${wordList.words[order].name}-${order}`}
word={wordList.words[order].name}
onFinish={onFinish}
isStart={isStart}
wordVisible={switcherState.wordVisible}
/>
{switcherState.phonetic && (wordList.words[order].usphone || wordList.words[order].ukphone) && (
<Phonetic usphone={wordList.words[order].usphone} ukphone={wordList.words[order].ukphone} />
)}
<Translation key={`trans-${wordList.words[order].name}`} trans={wordList.words[order].trans.join('')} />
</div>
)}
<Speed correctCount={correctCount} inputCount={inputCount} isStart={isStart} />
</div>
</Main>

View File

@@ -1,3 +1,5 @@
import { Howl } from 'howler'
export const isLegal = (val: string): boolean => /^[a-z_A-Z_._(_)_0-9'!,'?\-;[\]\\/]$/.test(val)
export const isChineseSymbol = (val: string): boolean =>
/[\u3002|\uff1f|\uff01|\uff0c|\u3001|\uff1b|\uff1a|\u201c|\u201d|\u2018|\u2019|\uff08|\uff09|\u300a|\u300b|\u3008|\u3009|\u3010|\u3011|\u300e|\u300f|\u300c|\u300d|\ufe43|\ufe44|\u3014|\u3015|\u2026|\u2014|\uff5e|\ufe4f|\uffe5]/.test(
@@ -17,3 +19,21 @@ export const IsDesktop = () => {
}
return flag
}
export function addHowlListener(howl: Howl, ...args: Parameters<Howl['on']>) {
howl.on(...args)
return () => howl.off(...args)
}
export function classNames(...classNames: Array<string | void | null>) {
const finallyClassNames: string[] = []
for (const className of classNames) {
if (className) {
finallyClassNames.push(className.trim())
}
}
return finallyClassNames.join(' ')
}

View File

@@ -1784,6 +1784,11 @@
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934"
integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==
"@types/howler@^2.2.3":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.3.tgz#236ff8ba2da2ed25b0b0ab959ad2f15effc93dff"
integrity sha512-rzIE56NmGh7t8LdZT/zuBMa9iZvwqVmB65gNhwNLtrGEIHdWkEDp1fVmoM/zctR4UJZFe0uP01AmPuPzOwhecA==
"@types/html-minifier-terser@^5.0.0":
version "5.1.1"
resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50"
@@ -5770,6 +5775,11 @@ howler@^2.1.3:
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.1.tgz#a521a9b495841e8bb9aa12e651bebba0affc179e"
integrity sha512-0iIXvuBO/81CcrQ/HSSweYmbT50fT2mIc9XMFb+kxIfk2pW/iKzDbX1n3fZmDXMEIpYvyyfrB+gXwPYSDqUxIQ==
howler@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.3.tgz#a2eff9b08b586798e7a2ee17a602a90df28715da"
integrity sha512-QM0FFkw0LRX1PR8pNzJVAY25JhIWvbKMBFM4gqk+QdV+kPXOhleWGCB6AiAF/goGjIHK2e/nIElplvjQwhr0jg==
hpack.js@^2.1.6:
version "2.1.6"
resolved "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
@@ -11640,10 +11650,10 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
use-sound@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/use-sound/-/use-sound-2.0.1.tgz#72e70d4bfdda71a6959e5adab0c7e10ff4080a0a"
integrity sha512-wNwnyIOe8QPDQgWRZd3PqVEJ9BpLO+VJX+EpQSb6EvvjblHhnYMD7WIOTQ4hgloC2jC7Az6FhclHilC441De7A==
use-sound@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/use-sound/-/use-sound-4.0.1.tgz#8c8de4badd16902db7762fd9418a4283b9bf7b3e"
integrity sha512-hykJ86kNcu6y/FzlSHcQxhjSGMslZx2WlfLpZNoPbvueakv4OF3xPxEtGV2YmculrIaH0tPp9LtG4jgy17xMWg==
dependencies:
howler "^2.1.3"