You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

521 lines
15 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, injectIntl } from 'react-intl';
  4. import { formatTime } from 'mastodon/features/video';
  5. import Icon from 'mastodon/components/icon';
  6. import classNames from 'classnames';
  7. import { throttle } from 'lodash';
  8. import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
  9. import { debounce } from 'lodash';
  10. import Visualizer from './visualizer';
  11. const messages = defineMessages({
  12. play: { id: 'video.play', defaultMessage: 'Play' },
  13. pause: { id: 'video.pause', defaultMessage: 'Pause' },
  14. mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
  15. unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
  16. download: { id: 'video.download', defaultMessage: 'Download file' },
  17. });
  18. const TICK_SIZE = 10;
  19. const PADDING = 180;
  20. export default @injectIntl
  21. class Audio extends React.PureComponent {
  22. static propTypes = {
  23. src: PropTypes.string.isRequired,
  24. alt: PropTypes.string,
  25. poster: PropTypes.string,
  26. duration: PropTypes.number,
  27. width: PropTypes.number,
  28. height: PropTypes.number,
  29. editable: PropTypes.bool,
  30. fullscreen: PropTypes.bool,
  31. intl: PropTypes.object.isRequired,
  32. cacheWidth: PropTypes.func,
  33. backgroundColor: PropTypes.string,
  34. foregroundColor: PropTypes.string,
  35. accentColor: PropTypes.string,
  36. currentTime: PropTypes.number,
  37. autoPlay: PropTypes.bool,
  38. volume: PropTypes.number,
  39. muted: PropTypes.bool,
  40. deployPictureInPicture: PropTypes.func,
  41. };
  42. state = {
  43. width: this.props.width,
  44. currentTime: 0,
  45. buffer: 0,
  46. duration: null,
  47. paused: true,
  48. muted: false,
  49. volume: 0.5,
  50. dragging: false,
  51. };
  52. constructor (props) {
  53. super(props);
  54. this.visualizer = new Visualizer(TICK_SIZE);
  55. }
  56. setPlayerRef = c => {
  57. this.player = c;
  58. if (this.player) {
  59. this._setDimensions();
  60. }
  61. }
  62. _pack() {
  63. return {
  64. src: this.props.src,
  65. volume: this.audio.volume,
  66. muted: this.audio.muted,
  67. currentTime: this.audio.currentTime,
  68. poster: this.props.poster,
  69. backgroundColor: this.props.backgroundColor,
  70. foregroundColor: this.props.foregroundColor,
  71. accentColor: this.props.accentColor,
  72. };
  73. }
  74. _setDimensions () {
  75. const width = this.player.offsetWidth;
  76. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  77. if (this.props.cacheWidth) {
  78. this.props.cacheWidth(width);
  79. }
  80. this.setState({ width, height });
  81. }
  82. setSeekRef = c => {
  83. this.seek = c;
  84. }
  85. setVolumeRef = c => {
  86. this.volume = c;
  87. }
  88. setAudioRef = c => {
  89. this.audio = c;
  90. if (this.audio) {
  91. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  92. }
  93. }
  94. setCanvasRef = c => {
  95. this.canvas = c;
  96. this.visualizer.setCanvas(c);
  97. }
  98. componentDidMount () {
  99. window.addEventListener('scroll', this.handleScroll);
  100. window.addEventListener('resize', this.handleResize, { passive: true });
  101. }
  102. componentDidUpdate (prevProps, prevState) {
  103. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
  104. this._clear();
  105. this._draw();
  106. }
  107. }
  108. componentWillUnmount () {
  109. window.removeEventListener('scroll', this.handleScroll);
  110. window.removeEventListener('resize', this.handleResize);
  111. if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
  112. this.props.deployPictureInPicture('audio', this._pack());
  113. }
  114. }
  115. togglePlay = () => {
  116. if (!this.audioContext) {
  117. this._initAudioContext();
  118. }
  119. if (this.state.paused) {
  120. this.setState({ paused: false }, () => this.audio.play());
  121. } else {
  122. this.setState({ paused: true }, () => this.audio.pause());
  123. }
  124. }
  125. handleResize = debounce(() => {
  126. if (this.player) {
  127. this._setDimensions();
  128. }
  129. }, 250, {
  130. trailing: true,
  131. });
  132. handlePlay = () => {
  133. this.setState({ paused: false });
  134. if (this.audioContext && this.audioContext.state === 'suspended') {
  135. this.audioContext.resume();
  136. }
  137. this._renderCanvas();
  138. }
  139. handlePause = () => {
  140. this.setState({ paused: true });
  141. if (this.audioContext) {
  142. this.audioContext.suspend();
  143. }
  144. }
  145. handleProgress = () => {
  146. const lastTimeRange = this.audio.buffered.length - 1;
  147. if (lastTimeRange > -1) {
  148. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  149. }
  150. }
  151. toggleMute = () => {
  152. const muted = !this.state.muted;
  153. this.setState({ muted }, () => {
  154. this.audio.muted = muted;
  155. });
  156. }
  157. handleVolumeMouseDown = e => {
  158. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  159. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  160. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  161. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  162. this.handleMouseVolSlide(e);
  163. e.preventDefault();
  164. e.stopPropagation();
  165. }
  166. handleVolumeMouseUp = () => {
  167. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  168. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  169. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  170. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  171. }
  172. handleMouseDown = e => {
  173. document.addEventListener('mousemove', this.handleMouseMove, true);
  174. document.addEventListener('mouseup', this.handleMouseUp, true);
  175. document.addEventListener('touchmove', this.handleMouseMove, true);
  176. document.addEventListener('touchend', this.handleMouseUp, true);
  177. this.setState({ dragging: true });
  178. this.audio.pause();
  179. this.handleMouseMove(e);
  180. e.preventDefault();
  181. e.stopPropagation();
  182. }
  183. handleMouseUp = () => {
  184. document.removeEventListener('mousemove', this.handleMouseMove, true);
  185. document.removeEventListener('mouseup', this.handleMouseUp, true);
  186. document.removeEventListener('touchmove', this.handleMouseMove, true);
  187. document.removeEventListener('touchend', this.handleMouseUp, true);
  188. this.setState({ dragging: false });
  189. this.audio.play();
  190. }
  191. handleMouseMove = throttle(e => {
  192. const { x } = getPointerPosition(this.seek, e);
  193. const currentTime = this.audio.duration * x;
  194. if (!isNaN(currentTime)) {
  195. this.setState({ currentTime }, () => {
  196. this.audio.currentTime = currentTime;
  197. });
  198. }
  199. }, 15);
  200. handleTimeUpdate = () => {
  201. this.setState({
  202. currentTime: this.audio.currentTime,
  203. duration: this.audio.duration,
  204. });
  205. }
  206. handleMouseVolSlide = throttle(e => {
  207. const { x } = getPointerPosition(this.volume, e);
  208. if(!isNaN(x)) {
  209. this.setState({ volume: x }, () => {
  210. this.audio.volume = x;
  211. });
  212. }
  213. }, 15);
  214. handleScroll = throttle(() => {
  215. if (!this.canvas || !this.audio) {
  216. return;
  217. }
  218. const { top, height } = this.canvas.getBoundingClientRect();
  219. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  220. if (!this.state.paused && !inView) {
  221. this.audio.pause();
  222. if (this.props.deployPictureInPicture) {
  223. this.props.deployPictureInPicture('audio', this._pack());
  224. }
  225. this.setState({ paused: true });
  226. }
  227. }, 150, { trailing: true });
  228. handleMouseEnter = () => {
  229. this.setState({ hovered: true });
  230. }
  231. handleMouseLeave = () => {
  232. this.setState({ hovered: false });
  233. }
  234. handleLoadedData = () => {
  235. const { autoPlay, currentTime, volume, muted } = this.props;
  236. if (currentTime) {
  237. this.audio.currentTime = currentTime;
  238. }
  239. if (volume !== undefined) {
  240. this.audio.volume = volume;
  241. }
  242. if (muted !== undefined) {
  243. this.audio.muted = muted;
  244. }
  245. if (autoPlay) {
  246. this.togglePlay();
  247. }
  248. }
  249. _initAudioContext () {
  250. const AudioContext = window.AudioContext || window.webkitAudioContext;
  251. const context = new AudioContext();
  252. const source = context.createMediaElementSource(this.audio);
  253. this.visualizer.setAudioContext(context, source);
  254. source.connect(context.destination);
  255. this.audioContext = context;
  256. }
  257. handleDownload = () => {
  258. fetch(this.props.src).then(res => res.blob()).then(blob => {
  259. const element = document.createElement('a');
  260. const objectURL = URL.createObjectURL(blob);
  261. element.setAttribute('href', objectURL);
  262. element.setAttribute('download', fileNameFromURL(this.props.src));
  263. document.body.appendChild(element);
  264. element.click();
  265. document.body.removeChild(element);
  266. URL.revokeObjectURL(objectURL);
  267. }).catch(err => {
  268. console.error(err);
  269. });
  270. }
  271. _renderCanvas () {
  272. requestAnimationFrame(() => {
  273. if (!this.audio) return;
  274. this.handleTimeUpdate();
  275. this._clear();
  276. this._draw();
  277. if (!this.state.paused) {
  278. this._renderCanvas();
  279. }
  280. });
  281. }
  282. _clear() {
  283. this.visualizer.clear(this.state.width, this.state.height);
  284. }
  285. _draw() {
  286. this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
  287. }
  288. _getRadius () {
  289. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  290. }
  291. _getScaleCoefficient () {
  292. return (this.state.height || this.props.height) / 982;
  293. }
  294. _getCX() {
  295. return Math.floor(this.state.width / 2);
  296. }
  297. _getCY() {
  298. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  299. }
  300. _getAccentColor () {
  301. return this.props.accentColor || '#ffffff';
  302. }
  303. _getBackgroundColor () {
  304. return this.props.backgroundColor || '#000000';
  305. }
  306. _getForegroundColor () {
  307. return this.props.foregroundColor || '#ffffff';
  308. }
  309. seekBy (time) {
  310. const currentTime = this.audio.currentTime + time;
  311. if (!isNaN(currentTime)) {
  312. this.setState({ currentTime }, () => {
  313. this.audio.currentTime = currentTime;
  314. });
  315. }
  316. }
  317. handleAudioKeyDown = e => {
  318. // On the audio element or the seek bar, we can safely use the space bar
  319. // for playback control because there are no buttons to press
  320. if (e.key === ' ') {
  321. e.preventDefault();
  322. e.stopPropagation();
  323. this.togglePlay();
  324. }
  325. }
  326. handleKeyDown = e => {
  327. switch(e.key) {
  328. case 'k':
  329. e.preventDefault();
  330. e.stopPropagation();
  331. this.togglePlay();
  332. break;
  333. case 'm':
  334. e.preventDefault();
  335. e.stopPropagation();
  336. this.toggleMute();
  337. break;
  338. case 'j':
  339. e.preventDefault();
  340. e.stopPropagation();
  341. this.seekBy(-10);
  342. break;
  343. case 'l':
  344. e.preventDefault();
  345. e.stopPropagation();
  346. this.seekBy(10);
  347. break;
  348. }
  349. }
  350. render () {
  351. const { src, intl, alt, editable, autoPlay } = this.props;
  352. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  353. const progress = Math.min((currentTime / duration) * 100, 100);
  354. return (
  355. <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
  356. <audio
  357. src={src}
  358. ref={this.setAudioRef}
  359. preload={autoPlay ? 'auto' : 'none'}
  360. onPlay={this.handlePlay}
  361. onPause={this.handlePause}
  362. onProgress={this.handleProgress}
  363. onLoadedData={this.handleLoadedData}
  364. crossOrigin='anonymous'
  365. />
  366. <canvas
  367. role='button'
  368. tabIndex='0'
  369. className='audio-player__canvas'
  370. width={this.state.width}
  371. height={this.state.height}
  372. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  373. ref={this.setCanvasRef}
  374. onClick={this.togglePlay}
  375. onKeyDown={this.handleAudioKeyDown}
  376. title={alt}
  377. aria-label={alt}
  378. />
  379. <img
  380. src={this.props.poster}
  381. alt=''
  382. width={(this._getRadius() - TICK_SIZE) * 2}
  383. height={(this._getRadius() - TICK_SIZE) * 2}
  384. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  385. />
  386. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  387. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  388. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  389. <span
  390. className={classNames('video-player__seek__handle', { active: dragging })}
  391. tabIndex='0'
  392. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  393. onKeyDown={this.handleAudioKeyDown}
  394. />
  395. </div>
  396. <div className='video-player__controls active'>
  397. <div className='video-player__buttons-bar'>
  398. <div className='video-player__buttons left'>
  399. <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
  400. <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
  401. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  402. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  403. <span
  404. className='video-player__volume__handle'
  405. tabIndex='0'
  406. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  407. />
  408. </div>
  409. <span className='video-player__time'>
  410. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  411. <span className='video-player__time-sep'>/</span>
  412. <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
  413. </span>
  414. </div>
  415. <div className='video-player__buttons right'>
  416. <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
  417. <Icon id={'download'} fixedWidth />
  418. </a>
  419. </div>
  420. </div>
  421. </div>
  422. </div>
  423. );
  424. }
  425. }