diff --git a/README.md b/README.md index 30325515..9f65c835 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](docs/useConditionalTimeout.md) * [useCookie](docs/useCookie.md) * [useMutationObserver](docs/useMutationObserver.md) +* [useAudio](docs/useAudio.md) +* [useObjectState](docs/useObjectState.md)

diff --git a/docs/README.es-ES.md b/docs/README.es-ES.md index ff02d769..cc2560b8 100644 --- a/docs/README.es-ES.md +++ b/docs/README.es-ES.md @@ -113,6 +113,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.it-IT.md b/docs/README.it-IT.md index 71d1cfbd..730551cf 100644 --- a/docs/README.it-IT.md +++ b/docs/README.it-IT.md @@ -113,6 +113,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.jp-JP.md b/docs/README.jp-JP.md index 61a4ff36..b5ba3f70 100644 --- a/docs/README.jp-JP.md +++ b/docs/README.jp-JP.md @@ -113,6 +113,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.pl-PL.md b/docs/README.pl-PL.md index b9b9b545..6a3745a6 100644 --- a/docs/README.pl-PL.md +++ b/docs/README.pl-PL.md @@ -116,6 +116,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index 09cee7f4..fe28261e 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -115,6 +115,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.uk-UA.md b/docs/README.uk-UA.md index a42d91de..6cefc6d6 100644 --- a/docs/README.uk-UA.md +++ b/docs/README.uk-UA.md @@ -114,6 +114,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 3550cc6e..5a15a188 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -111,6 +111,8 @@ $ yarn add beautiful-react-hooks * [useConditionalTimeout](useConditionalTimeout.md) * [useCookie](useCookie.md) * [useMutationObserver](useMutationObserver.md) +* [useAudio](useAudio.md) +* [useObjectState](useObjectState.md)

diff --git a/docs/useAudio.md b/docs/useAudio.md new file mode 100644 index 00000000..01bbcec9 --- /dev/null +++ b/docs/useAudio.md @@ -0,0 +1,52 @@ +# useAudio + +Creates

State:

+

{JSON.stringify(state, null, 2)}

+ + + + + ); +}; + + +``` + +### Mastering the hook + +#### ✅ When to use + +- When in need of manage the state which is an object, diff --git a/src/useAudio.ts b/src/useAudio.ts new file mode 100644 index 00000000..a5a39ddb --- /dev/null +++ b/src/useAudio.ts @@ -0,0 +1,289 @@ +import { + MutableRefObject, + useCallback, + useEffect, + useRef, + RefObject, +} from 'react' + +import noop from './shared/noop' +import isClient from './shared/isClient' +import useObjectState from './useObjectState' +import { CallbackSetter } from './shared/types' +import isDevelopment from './shared/isDevelopment' +import isAPISupported from './shared/isAPISupported' +import createHandlerSetter from './factory/createHandlerSetter' + +type TPreload = 'auto' | 'metadata' | 'none'; + +type TAudioRef = RefObject; + +type TOutput = [IState, IControls, TAudioRef]; + +export interface IOptions { + loop?: boolean; + muted?: boolean; + volume?: number; + autoPlay?: boolean; + preload?: TPreload; + playbackRate?: number; +} + +interface IState { + loop: boolean; + muted: boolean; + volume: number; + duration: number; + autoPlay: boolean; + isPlaying: boolean; + preload?: TPreload; + currentTime: number; + playbackRate: number; + isSrcLoading: boolean | undefined; +} + +interface IControls { + play: () => void; + mute: () => void; + pause: () => void; + unmute: () => void; + seek: (time: number) => void; + onError: CallbackSetter; + setVolume: (volume: number) => void; +} + +const defaultOptions: Required = { + volume: 1, + loop: false, + muted: false, + playbackRate: 1, + autoPlay: false, + preload: 'auto', +} + +const defaultState: IState = { + duration: 0, + currentTime: 0, + isPlaying: false, + isSrcLoading: undefined, + ...defaultOptions, +} + +const errorEventCodeToMessageMapper: Record = { + 3: 'MEDIA_ERR_DECODE - error occurred when decoding', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED - audio not supported', + 2: 'MEDIA_ERR_NETWORK - error occurred when downloading', + 1: 'MEDIA_ERR_ABORTED - fetching process aborted by user', +} + +const hookNotSupportedControls: IControls = Object.freeze({ + seek: noop, + play: noop, + mute: noop, + pause: noop, + unmute: noop, + onError: noop, + setVolume: noop, +}) + +const checkIfRefElementExists = ( + ref: MutableRefObject, + callback: (element: TElement) => unknown, +) => () => { + const element = ref.current + + if (!element) { + return undefined + } + + return callback(element) + } + +export const useAudio = (src: string, options?: IOptions): TOutput => { + const hookNotSupportedResponse: TOutput = [ + defaultState, + hookNotSupportedControls, + useRef(null), + ] + + if (!isClient) { + if (!isDevelopment) { + // eslint-disable-next-line no-console + console.warn( + 'Please be aware that useAudio hook could not be available during SSR', + ) + } + + return hookNotSupportedResponse + } + + if (!isAPISupported('Audio')) { + // eslint-disable-next-line no-console + console.warn( + "The current device does not support the 'Audio' API, you should avoid using useAudio hook", + ) + + return hookNotSupportedResponse + } + + const audioRef = useRef(new Audio(src)) + const [onErrorRef, setOnErrorRef] = createHandlerSetter() + + const [state, setState] = useObjectState(defaultState) + + const onError = (error: Error) => { + if (onErrorRef.current) { + onErrorRef.current(error) + } + } + + const play = useCallback( + checkIfRefElementExists(audioRef, (element) => element + .play() + .then(() => { + setState({ + isPlaying: true, + }) + }) + .catch(onError)), + [], + ) + + const pause = useCallback( + checkIfRefElementExists(audioRef, (element) => { + element.pause() + + setState({ + isPlaying: false, + }) + }), + [], + ) + + const mute = useCallback( + checkIfRefElementExists(audioRef, (element) => { + // eslint-disable-next-line no-param-reassign + element.muted = true + + setState({ + muted: true, + }) + }), + [], + ) + + const unmute = useCallback( + checkIfRefElementExists(audioRef, (element) => { + // eslint-disable-next-line no-param-reassign + element.muted = false + + setState({ + muted: false, + }) + }), + [], + ) + + const seek = useCallback( + (time: number) => checkIfRefElementExists(audioRef, (element) => { + const newTime = time >= 0 ? Math.min(time, element.duration) : Math.max(time, 0) + + // eslint-disable-next-line no-param-reassign + element.currentTime = newTime + + setState({ + currentTime: newTime, + }) + })(), + [], + ) + + const setVolume = useCallback( + (volume: number) => checkIfRefElementExists(audioRef, (element) => { + const newVolume = volume >= 0 ? Math.min(volume, 1) : Math.max(volume, 0) + + // eslint-disable-next-line no-param-reassign + element.volume = newVolume + + setState({ + volume: newVolume, + }) + })(), + [], + ) + + const onLoadedData = checkIfRefElementExists(audioRef, (element) => setState({ + isSrcLoading: false, + duration: element.duration, + currentTime: element.currentTime, + })) + + const onTimeUpdate = checkIfRefElementExists(audioRef, (element) => setState({ + currentTime: element.currentTime, + })) + + const errorEventCallback = () => { + const element = audioRef.current + const errorCode = element.error.code + const errorMessage = element.error.message + || errorEventCodeToMessageMapper[errorCode] + || 'UNKNOWN' + + onError(new Error(errorMessage)) + } + + useEffect(() => { + const element = audioRef.current! + + if (!element) { + return + } + + const mergedOptions = { ...defaultOptions, ...options } + + element.loop = mergedOptions.loop + element.muted = mergedOptions.muted + element.volume = mergedOptions.volume + element.preload = mergedOptions.preload + element.autoplay = mergedOptions.autoPlay + element.playbackRate = mergedOptions.playbackRate + + setState({ + ...mergedOptions, + isSrcLoading: true, + }) + + element.addEventListener('loadeddata', onLoadedData) + element.addEventListener('timeupdate', onTimeUpdate) + element.addEventListener('error', errorEventCallback) + + // eslint-disable-next-line consistent-return + return () => { + element.removeEventListener('loadeddata', onLoadedData) + element.removeEventListener('timeupdate', onTimeUpdate) + element.removeEventListener('error', errorEventCallback) + + pause() + } + }, []) + + useEffect(() => { + if (state.isSrcLoading === false && state.autoPlay) { + play() + } + }, [state.isSrcLoading, state.autoPlay]) + + const controls = Object.freeze({ + seek, + play, + mute, + pause, + unmute, + setVolume, + onError: setOnErrorRef, + }) + + return [state, controls, audioRef] +} + +export default useAudio diff --git a/src/useObjectState.ts b/src/useObjectState.ts new file mode 100644 index 00000000..48b97121 --- /dev/null +++ b/src/useObjectState.ts @@ -0,0 +1,24 @@ +import { useReducer } from 'react' + +const reducer = ( + previousState: TState, + updatedState: Partial, +) => ({ + ...previousState, + ...updatedState, + }) + +const useObjectState = ( + initialState: TState, +): [TState, (state: Partial) => void] => { + const [state, dispatch] = useReducer( + (previousState: TState, updatedState: Partial) => reducer(previousState, updatedState), + initialState, + ) + + const setState = (updatedState: Partial): void => dispatch(updatedState) + + return [state, setState] +} + +export default useObjectState diff --git a/test/mocks/AudioApi.mock.js b/test/mocks/AudioApi.mock.js new file mode 100644 index 00000000..bed92583 --- /dev/null +++ b/test/mocks/AudioApi.mock.js @@ -0,0 +1,28 @@ +class AudioApiMock extends window.Audio { + src; + state; + duration; + playing; + constructor() { + super(); + + this.src = ""; + this.duration = NaN; + this.playing = false; + this.state = "STOPPED"; + } + play = () => { + this.playing = true; + this.state = "PLAYING"; + + return super.play(); + }; + pause = () => { + this.playing = false; + this.state = "PAUSED"; + + return super.pause(); + }; +} + +export default AudioApiMock; diff --git a/test/useAudio.spec.js b/test/useAudio.spec.js new file mode 100644 index 00000000..3fd7dad6 --- /dev/null +++ b/test/useAudio.spec.js @@ -0,0 +1,111 @@ +import { + cleanup, + renderHook, +} from "@testing-library/react-hooks"; + +import useAudio from "../dist/useAudio"; +import assertHook from "./utils/assertHook"; + +import AudioMock from "./mocks/AudioApi.mock"; + +const validAudioUrl = + "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"; + +describe("useAudio", () => { + const originalAudio = global.Audio; + + before(() => { + global.Audio = window.Audio = AudioMock; + }); + + beforeEach(() => { + cleanup(); + }); + + after(() => { + global.Audio = window.Audio = originalAudio; + }); + + assertHook(useAudio); + + describe("when the Audio API is not supported", () => { + beforeEach(() => { + delete global.Audio; + delete window.Audio; + }); + + afterEach(() => { + global.Audio = window.Audio = originalAudio; + sinon.restore(); + }); + + it("should not play anything", async () => { + const warnSpy = sinon.spy(console, "warn"); + + const { result } = renderHook(() => useAudio(validAudioUrl)); + + const [state, controls, audio] = result.current; + + expect(warnSpy.called).to.be.true; + expect(audio.current).to.be.null; + expect(controls) + .to.be.an("object") + .that.has.all.deep.keys( + "play", + "mute", + "pause", + "unmute", + "seek", + "onError", + "setVolume" + ); + expect(state) + .to.be.an("object") + .that.has.all.deep.keys( + "loop", + "muted", + "playbackRate", + "volume", + "currentTime", + "duration", + "isPlaying", + "autoPlay", + "isSrcLoading", + "preload" + ); + }); + }); + + it("should return state, controls and audioRef", async () => { + const { result } = renderHook(() => useAudio(validAudioUrl)); + + const [state, controls, audio] = result.current; + + expect(audio.current).to.not.be.undefined; + expect(controls) + .to.be.an("object") + .that.has.all.deep.keys( + "play", + "mute", + "pause", + "unmute", + "seek", + "onError", + "setVolume" + ); + expect(state) + .to.be.an("object") + .that.has.all.deep.keys( + "loop", + "muted", + "playbackRate", + "volume", + "currentTime", + "duration", + "isPlaying", + "autoPlay", + "isSrcLoading", + "preload" + ); + }); +}); diff --git a/test/useMutationObserver.spec.js b/test/useMutationObserver.spec.js index bcff9cfa..72df8bb4 100644 --- a/test/useMutationObserver.spec.js +++ b/test/useMutationObserver.spec.js @@ -1,7 +1,7 @@ import React, { useRef, useState } from 'react' import MutationObserverMock from 'mutation-observer' -import { cleanup as cleanupHooks, renderHook } from '@testing-library/react-hooks' import { cleanup as cleanupReact, render } from '@testing-library/react' +import { cleanup as cleanupHooks, renderHook } from '@testing-library/react-hooks' import assertHook from './utils/assertHook' import promiseDelay from './utils/promiseDelay' diff --git a/test/useObjectState.spec.js b/test/useObjectState.spec.js new file mode 100644 index 00000000..bf4e1000 --- /dev/null +++ b/test/useObjectState.spec.js @@ -0,0 +1,32 @@ +import { act, cleanup, renderHook } from "@testing-library/react-hooks"; + +import assertHook from "./utils/assertHook"; +import useObjectState from "../dist/useObjectState"; + +describe("useObjectState", () => { + beforeEach(() => cleanup()); + + assertHook(useObjectState); + + it("should return updated object state", async () => { + const { result, waitFor } = renderHook(() => + useObjectState({ test: "test", test1: "test1" }) + ); + + const [state, setState] = result.current; + + expect(state) + .to.be.an("object") + .that.has.deep.equal({ test: "test", test1: "test1" }); + + act(() => { + setState({ test1: "it works" }); + }); + + await waitFor(() => { + expect(result.current[0]) + .to.be.an("object") + .that.has.deep.equal({ test: "test", test1: "it works" }); + }); + }); +});