diff --git a/.vscode/settings.json b/.vscode/settings.json index 02c48f6..9e1fcd6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "cascader", "contacto", "datepicker", - "listbox" + "listbox", + "wavesurfer" ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1f63381..11e7ada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@contacto-io/style-guide", - "version": "0.5.0", + "version": "0.5.10", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1d6dc92..bdd0cf3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "registry": "https://npm.pkg.github.com" }, "repository": "git://github.com/contacto-io/contacto-console", - "version": "0.5.0", + "version": "0.5.10", "main": "build/index.js", "module": "build/index.es.js", "files": [ @@ -105,6 +105,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-sortable-hoc": "^2.0.0", + "wavesurfer.js": "^7.0.3", "simplebar": "^5.3.5", "simplebar-react": "^2.3.5" } diff --git a/src/components/AudioPlayer/components/PlayPauseIcon.js b/src/components/AudioPlayer/components/PlayPauseIcon.js new file mode 100644 index 0000000..8e7eded --- /dev/null +++ b/src/components/AudioPlayer/components/PlayPauseIcon.js @@ -0,0 +1,36 @@ +/* eslint-disable max-len */ +import React from 'react' + +export default function PlayPauseIcon({ isPlaying = false, size = 32 }) { + if (!isPlaying) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/src/components/AudioPlayer/components/PlaybackSpeed.js b/src/components/AudioPlayer/components/PlaybackSpeed.js new file mode 100644 index 0000000..30f8dd6 --- /dev/null +++ b/src/components/AudioPlayer/components/PlaybackSpeed.js @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from 'react' +import { Popover } from 'antd' +import { Button } from '../../Button/index' + +const speeds = [0.8, 1, 1.2, 1.5, 1.7, 2, 2.5] +export default function PlaybackSpeed({ waveSurfer }) { + const [speed, setSpeed] = useState(waveSurfer?.getPlaybackRate()) + + const handleSpeedChange = (speed) => { + setSpeed(speed) + waveSurfer?.setPlaybackRate(speed) + } + + useEffect(() => { + setSpeed(waveSurfer?.getPlaybackRate()) + }, [waveSurfer]) + + return ( + ( +
+ {speeds.map((speed) => ( + + ))} +
+ )} + > + +
+ ) +} diff --git a/src/components/AudioPlayer/helpers/useWaveSurfer.js b/src/components/AudioPlayer/helpers/useWaveSurfer.js new file mode 100644 index 0000000..326c422 --- /dev/null +++ b/src/components/AudioPlayer/helpers/useWaveSurfer.js @@ -0,0 +1,83 @@ +import { useLayoutEffect, useRef, useState } from 'react' + +import WaveSurfer from 'wavesurfer.js' +import { generateId } from './utils' + +const defaultPlayerConfig = (playerId) => ({ + id: playerId, + isPlaying: false, + loading: true, +}) +const defaultDurationConfig = () => ({ + totalDuration: 0, + currentDuration: 0, +}) +export default function useWaveSurfer(url) { + const playerId = useRef(generateId('contacto-player-wave-')).current + const [playerConfig, setPlayerConfig] = useState(defaultPlayerConfig(playerId)) + const [durationConfig, setDurationConfig] = useState(defaultDurationConfig()) + const [waveSurfer, setWaveSurfer] = useState(null) + + useLayoutEffect(() => { + let wave = null + if (url) { + wave = WaveSurfer.create({ + container: `#${playerId}`, + url: url, + waveColor: '#C3D2FF', + progressColor: '#0040E4', + cursorColor: '#0040E4', + responsive: true, + height: 24, + barHeight: 3, + barMinHeight: 1, + barWidth: 1, + barGap: 4, + hideScrollbar: true, + closeAudioContext: true, + partialRender: true, + }) + wave.on('load', () => { + setPlayerConfig((prevConfig) => ({ ...prevConfig, loading: true })) + }) + wave.on('ready', () => { + setDurationConfig((prevConfig) => ({ + ...prevConfig, + totalDuration: parseInt(wave.getDuration()), + })) + setPlayerConfig((prevConfig) => ({ ...prevConfig, loading: false })) + }) + wave.on('audioprocess', () => { + const duration = parseInt(wave.getCurrentTime()) + if (durationConfig.currentDuration !== duration) { + setDurationConfig((prevConfig) => ({ + ...prevConfig, + currentDuration: duration, + })) + } + }) + wave.on('play', () => { + setPlayerConfig((prevConfig) => ({ ...prevConfig, isPlaying: true })) + }) + wave.on('pause', () => { + setPlayerConfig((prevConfig) => ({ ...prevConfig, isPlaying: false })) + }) + wave.on('finish', () => { + setPlayerConfig((prevConfig) => ({ ...prevConfig, isPlaying: false })) + }) + + setWaveSurfer(wave) + } + + return () => { + wave?.stop() + wave?.destroy() + setWaveSurfer(null) + setPlayerConfig(defaultPlayerConfig(playerId)) + setDurationConfig(defaultDurationConfig()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerId, url]) + + return { waveSurfer, playerConfig, durationConfig } +} diff --git a/src/components/AudioPlayer/helpers/utils.js b/src/components/AudioPlayer/helpers/utils.js new file mode 100644 index 0000000..baa47a3 --- /dev/null +++ b/src/components/AudioPlayer/helpers/utils.js @@ -0,0 +1,12 @@ +export const getDisplayTime = (seconds) => { + const int = +seconds / 60 + let minutes = parseInt(int) + seconds = seconds % 60 + minutes = (minutes < 10 ? '0' : '') + minutes + seconds = (seconds < 10 ? '0' : '') + seconds + return minutes + ':' + seconds +} + +export const generateId = (prefix) => { + return `${prefix}${Math.random().toString(36).slice(2)}` +} diff --git a/src/components/AudioPlayer/index.js b/src/components/AudioPlayer/index.js new file mode 100644 index 0000000..73dfb42 --- /dev/null +++ b/src/components/AudioPlayer/index.js @@ -0,0 +1,45 @@ +import React from 'react' +import Text from 'antd/lib/typography/Text' +import { forwardRef } from 'react' +import PlayPauseIcon from './components/PlayPauseIcon' +import useWaveSurfer from './helpers/useWaveSurfer' +import PlaybackSpeed from './components/PlaybackSpeed' +import { Button } from '../Button/index' +import { Icon } from '../Icon/index' +import { getDisplayTime } from './helpers/utils' +import './styles.scss' + +const AudioPlayer = forwardRef((props, ref) => { + const { className, url } = props + const { waveSurfer, playerConfig, durationConfig } = useWaveSurfer(url) + + const { isPlaying, loading } = playerConfig + const { totalDuration, currentDuration } = durationConfig + + return ( +
+
+
+ ) +}) + +AudioPlayer.displayName = 'AudioPlayer' +export { AudioPlayer } diff --git a/src/components/AudioPlayer/index.stories.js b/src/components/AudioPlayer/index.stories.js new file mode 100755 index 0000000..771601b --- /dev/null +++ b/src/components/AudioPlayer/index.stories.js @@ -0,0 +1,14 @@ +import React from 'react' +import { AudioPlayer } from './' + +export default { + title: 'Components/Audio Player', + component: AudioPlayer, +} + +const Template = (args) => + +export const Default = Template.bind({}) +Default.args = { + url: 'https://wavesurfer-js.org/wavesurfer-code/examples/audio/mono.mp3', +} diff --git a/src/components/AudioPlayer/styles.scss b/src/components/AudioPlayer/styles.scss new file mode 100644 index 0000000..b59da7a --- /dev/null +++ b/src/components/AudioPlayer/styles.scss @@ -0,0 +1,128 @@ +@mixin playback-speed-button { + display: flex; + width: 32px; + height: 26px !important; + min-width: unset !important; + justify-content: center; + background: transparent; + border-radius: 4px !important; + align-items: center; + padding: 0; + font-size: 12px; + font-weight: 400; + transition: none; + + &:hover { + background: transparent !important; + } + + &.contacto-button--table-action-link { + background-color: var(--background-highlight-color); + + &:hover { + background-color: var(--background-highlight-color) !important; + } + } +} + +.contacto-audio-player { + .audio-controls { + height: 32px; + display: flex; + justify-content: center; + align-items: center; + + .audio-controls-play-pause { + min-width: 38px; + max-width: 38px; + height: 36px; + padding: 0; + border: none; + box-shadow: none; + + &::after { + display: none; + } + } + .audio-controls-wave-bar { + flex: 1; + margin-left: 8px; + margin-right: 8px; + } + + .audio-controls-time { + width: max-content; + min-width: max-content; + + .ant-typography { + color: var(--gray-1); + } + + &.left { + margin-left: 16px; + } + } + + .audio-controls-wave-bar { + height: 24px; + background-color: transparent; + overflow: hidden; + transition: height 0.3s ease-in-out; + display: flex; + align-items: center; + cursor: pointer; + + div { + width: 100%; + } + } + + .contacto-player-speed-trigger { + @include playback-speed-button; + margin-left: 32px; + } + } + &.loading { + .audio-controls-wave-bar { + height: 2px; + background-color: var(--gray-4); + } + } +} + +.contacto-player-speed { + padding-top: 8px; + + .ant-popover-arrow { + display: none; + } + .ant-popover-inner { + padding: 16px; + box-shadow: var(--box-shadow-default); + + .ant-popover-title { + height: unset; + min-height: unset; + border: none; + padding: 0; + font-size: 12px; + font-weight: 600; + color: var(--primary-text-color); + margin-bottom: 8px; + } + .ant-popover-inner-content { + padding: 0; + + div { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + .contacto-player-speed { + @include playback-speed-button; + } + } + } + } +} diff --git a/src/index.js b/src/index.js index 4343216..e9f5e39 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +export * from './components/AudioPlayer/index' export * from './components/Button/index' export * from './components/Textfield/index' export * from './components/Typography/index'