diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js deleted file mode 100644 index 98bfc66b50..0000000000 Binary files a/app/soapbox/features/audio/index.js and /dev/null differ diff --git a/app/soapbox/features/audio/index.tsx b/app/soapbox/features/audio/index.tsx new file mode 100644 index 0000000000..9910ab73cb --- /dev/null +++ b/app/soapbox/features/audio/index.tsx @@ -0,0 +1,600 @@ +import classNames from 'clsx'; +import debounce from 'lodash/debounce'; +import throttle from 'lodash/throttle'; +import React, { useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Icon from 'soapbox/components/icon'; +import { formatTime, getPointerPosition } from 'soapbox/features/video'; + +import Visualizer from './visualizer'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + download: { id: 'video.download', defaultMessage: 'Download file' }, +}); + +const TICK_SIZE = 10; +const PADDING = 180; + +interface IAudio { + src: string, + alt?: string, + poster?: string, + duration?: number, + width?: number, + height?: number, + editable?: boolean, + fullscreen?: boolean, + cacheWidth?: (width: number) => void, + backgroundColor?: string, + foregroundColor?: string, + accentColor?: string, + currentTime?: number, + autoPlay?: boolean, + volume?: number, + muted?: boolean, + deployPictureInPicture?: (type: string, opts: Record) => void, +} + +const Audio: React.FC = (props) => { + const { + src, + alt = '', + poster, + accentColor, + backgroundColor, + foregroundColor, + cacheWidth, + fullscreen, + autoPlay, + editable, + deployPictureInPicture = false, + } = props; + + const intl = useIntl(); + + const [width, setWidth] = useState(props.width); + const [height, setHeight] = useState(props.height); + const [currentTime, setCurrentTime] = useState(0); + const [buffer, setBuffer] = useState(0); + const [duration, setDuration] = useState(undefined); + const [paused, setPaused] = useState(true); + const [muted, setMuted] = useState(false); + const [volume, setVolume] = useState(0.5); + const [dragging, setDragging] = useState(false); + const [hovered, setHovered] = useState(false); + + const visualizer = useRef(new Visualizer(TICK_SIZE)); + const audioContext = useRef(null); + + const player = useRef(null); + const audio = useRef(null); + const seek = useRef(null); + const slider = useRef(null); + const canvas = useRef(null); + + const _pack = () => ({ + src: props.src, + volume: audio.current?.volume, + muted: audio.current?.muted, + currentTime: audio.current?.currentTime, + poster: props.poster, + backgroundColor: props.backgroundColor, + foregroundColor: props.foregroundColor, + accentColor: props.accentColor, + }); + + const _setDimensions = () => { + if (player.current) { + const width = player.current.offsetWidth; + const height = fullscreen ? player.current.offsetHeight : (width / (16 / 9)); + + if (cacheWidth) { + cacheWidth(width); + } + + setWidth(width); + setHeight(height); + } + }; + + useEffect(() => { + if (player.current) { + _setDimensions(); + } + }, [player.current]); + + useEffect(() => { + if (audio.current) { + setVolume(audio.current.volume); + setMuted(audio.current.muted); + } + }, [audio.current]); + + useEffect(() => { + if (canvas.current && visualizer.current) { + visualizer.current.setCanvas(canvas.current); + } + }, [canvas.current, visualizer.current]); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + window.addEventListener('resize', handleResize, { passive: true }); + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + + if (!paused && audio.current && deployPictureInPicture) { + deployPictureInPicture('audio', _pack()); + } + }; + }, []); + + useEffect(() => { + _clear(); + _draw(); + }, [src, width, height, accentColor]); + + const togglePlay = () => { + if (!audioContext.current) { + _initAudioContext(); + } + + if (paused) { + audio.current?.play(); + } else { + audio.current?.pause(); + } + + setPaused(!paused); + }; + + const handleResize = debounce(() => { + if (player.current) { + _setDimensions(); + } + }, 250, { + trailing: true, + }); + + const handlePlay = () => { + setPaused(false); + + if (audioContext.current?.state === 'suspended') { + audioContext.current?.resume(); + } + + _renderCanvas(); + }; + + const handlePause = () => { + setPaused(true); + audioContext.current?.suspend(); + }; + + const handleProgress = () => { + if (audio.current) { + const lastTimeRange = audio.current.buffered.length - 1; + + if (lastTimeRange > -1) { + setBuffer(Math.ceil(audio.current.buffered.end(lastTimeRange) / audio.current.duration * 100)); + } + } + }; + + const toggleMute = () => { + const nextMuted = !muted; + + setMuted(nextMuted); + + if (audio.current) { + audio.current.muted = nextMuted; + } + }; + + const handleVolumeMouseDown: React.MouseEventHandler = e => { + document.addEventListener('mousemove', handleMouseVolSlide, true); + document.addEventListener('mouseup', handleVolumeMouseUp, true); + document.addEventListener('touchmove', handleMouseVolSlide, true); + document.addEventListener('touchend', handleVolumeMouseUp, true); + + handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', handleMouseVolSlide, true); + document.removeEventListener('mouseup', handleVolumeMouseUp, true); + document.removeEventListener('touchmove', handleMouseVolSlide, true); + document.removeEventListener('touchend', handleVolumeMouseUp, true); + }; + + const handleMouseDown: React.MouseEventHandler = e => { + document.addEventListener('mousemove', handleMouseMove, true); + document.addEventListener('mouseup', handleMouseUp, true); + document.addEventListener('touchmove', handleMouseMove, true); + document.addEventListener('touchend', handleMouseUp, true); + + setDragging(true); + audio.current?.pause(); + handleMouseMove(e); + + e.preventDefault(); + e.stopPropagation(); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove, true); + document.removeEventListener('mouseup', handleMouseUp, true); + document.removeEventListener('touchmove', handleMouseMove, true); + document.removeEventListener('touchend', handleMouseUp, true); + + setDragging(false); + audio.current?.play(); + }; + + const handleMouseMove = throttle((e) => { + if (audio.current && seek.current) { + const { x } = getPointerPosition(seek.current, e); + const currentTime = audio.current.duration * x; + + if (!isNaN(currentTime)) { + setCurrentTime(currentTime); + audio.current.currentTime = currentTime; + } + } + }, 15); + + const handleTimeUpdate = () => { + if (audio.current) { + setCurrentTime(audio.current.currentTime); + setDuration(audio.current.duration); + } + }; + + const handleMouseVolSlide = throttle(e => { + if (audio.current && slider.current) { + const { x } = getPointerPosition(slider.current, e); + + if (!isNaN(x)) { + setVolume(x); + audio.current.volume = x; + } + } + }, 15); + + const handleScroll = throttle(() => { + if (!canvas.current || !audio.current) { + return; + } + + const { top, height } = canvas.current.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!paused && !inView) { + audio.current.pause(); + + if (deployPictureInPicture) { + deployPictureInPicture('audio', _pack()); + } + + setPaused(true); + } + }, 150, { trailing: true }); + + const handleMouseEnter = () => { + setHovered(true); + }; + + const handleMouseLeave = () => { + setHovered(false); + }; + + const handleLoadedData = () => { + if (audio.current) { + setDuration(audio.current.duration); + + if (currentTime) { + audio.current.currentTime = currentTime; + } + + if (volume !== undefined) { + audio.current.volume = volume; + } + + if (muted !== undefined) { + audio.current.muted = muted; + } + + if (autoPlay) { + togglePlay(); + } + } + }; + + const _initAudioContext = () => { + if (audio.current) { + // @ts-ignore + // eslint-disable-next-line compat/compat + const AudioContext = window.AudioContext || window.webkitAudioContext; + const context = new AudioContext(); + const source = context.createMediaElementSource(audio.current); + + visualizer.current.setAudioContext(context, source); + source.connect(context.destination); + + audioContext.current = context; + } + }; + + // const handleDownload = () => { + // fetch(src).then(res => res.blob()).then(blob => { + // const element = document.createElement('a'); + // const objectURL = URL.createObjectURL(blob); + + // element.setAttribute('href', objectURL); + // element.setAttribute('download', fileNameFromURL(src)); + + // document.body.appendChild(element); + // element.click(); + // document.body.removeChild(element); + + // URL.revokeObjectURL(objectURL); + // }).catch(err => { + // console.error(err); + // }); + // }; + + const _renderCanvas = () => { + requestAnimationFrame(() => { + if (!audio.current) return; + + handleTimeUpdate(); + _clear(); + _draw(); + + if (!paused) { + _renderCanvas(); + } + }); + }; + + const _clear = () => { + visualizer.current?.clear(width || 0, height || 0); + }; + + const _draw = () => { + visualizer.current?.draw(_getCX(), _getCY(), _getAccentColor(), _getRadius(), _getScaleCoefficient()); + }; + + const _getRadius = (): number => { + return ((height || props.height || 0) - (PADDING * _getScaleCoefficient()) * 2) / 2; + }; + + const _getScaleCoefficient = (): number => { + return (height || props.height || 0) / 982; + }; + + const _getCX = (): number => { + return Math.floor((width || 0) / 2); + }; + + const _getCY = (): number => { + return Math.floor(_getRadius() + (PADDING * _getScaleCoefficient())); + }; + + const _getAccentColor = (): string => { + return accentColor || '#ffffff'; + }; + + const _getBackgroundColor = (): string => { + return backgroundColor || '#000000'; + }; + + const _getForegroundColor = (): string => { + return foregroundColor || '#ffffff'; + }; + + const seekBy = (time: number) => { + if (audio.current) { + const currentTime = audio.current.currentTime + time; + + if (!isNaN(currentTime)) { + setCurrentTime(currentTime); + audio.current.currentTime = currentTime; + } + } + }; + + const handleAudioKeyDown: React.KeyboardEventHandler = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + togglePlay(); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + switch (e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + seekBy(10); + break; + } + }; + + const getDuration = () => duration || props.duration || 0; + const progress = Math.min((currentTime / getDuration()) * 100, 100); + + return ( +
+