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.

552 lines
16 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. const hex2rgba = (hex, alpha = 1) => {
  11. const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
  12. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  13. };
  14. const messages = defineMessages({
  15. play: { id: 'video.play', defaultMessage: 'Play' },
  16. pause: { id: 'video.pause', defaultMessage: 'Pause' },
  17. mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
  18. unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
  19. download: { id: 'video.download', defaultMessage: 'Download file' },
  20. });
  21. const TICK_SIZE = 10;
  22. const PADDING = 180;
  23. export default @injectIntl
  24. class Audio extends React.PureComponent {
  25. static propTypes = {
  26. src: PropTypes.string.isRequired,
  27. alt: PropTypes.string,
  28. poster: PropTypes.string,
  29. duration: PropTypes.number,
  30. width: PropTypes.number,
  31. height: PropTypes.number,
  32. editable: PropTypes.bool,
  33. fullscreen: PropTypes.bool,
  34. intl: PropTypes.object.isRequired,
  35. cacheWidth: PropTypes.func,
  36. backgroundColor: PropTypes.string,
  37. foregroundColor: PropTypes.string,
  38. accentColor: PropTypes.string,
  39. };
  40. state = {
  41. width: this.props.width,
  42. currentTime: 0,
  43. buffer: 0,
  44. duration: null,
  45. paused: true,
  46. muted: false,
  47. volume: 0.5,
  48. dragging: false,
  49. };
  50. setPlayerRef = c => {
  51. this.player = c;
  52. if (this.player) {
  53. this._setDimensions();
  54. }
  55. }
  56. _setDimensions () {
  57. const width = this.player.offsetWidth;
  58. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  59. if (this.props.cacheWidth) {
  60. this.props.cacheWidth(width);
  61. }
  62. this.setState({ width, height });
  63. }
  64. setSeekRef = c => {
  65. this.seek = c;
  66. }
  67. setVolumeRef = c => {
  68. this.volume = c;
  69. }
  70. setAudioRef = c => {
  71. this.audio = c;
  72. if (this.audio) {
  73. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  74. }
  75. }
  76. setCanvasRef = c => {
  77. this.canvas = c;
  78. if (c) {
  79. this.canvasContext = c.getContext('2d');
  80. }
  81. }
  82. componentDidMount () {
  83. window.addEventListener('scroll', this.handleScroll);
  84. window.addEventListener('resize', this.handleResize, { passive: true });
  85. }
  86. componentDidUpdate (prevProps, prevState) {
  87. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height) {
  88. this._clear();
  89. this._draw();
  90. }
  91. }
  92. componentWillUnmount () {
  93. window.removeEventListener('scroll', this.handleScroll);
  94. window.removeEventListener('resize', this.handleResize);
  95. }
  96. togglePlay = () => {
  97. if (this.state.paused) {
  98. this.setState({ paused: false }, () => this.audio.play());
  99. } else {
  100. this.setState({ paused: true }, () => this.audio.pause());
  101. }
  102. }
  103. handleResize = debounce(() => {
  104. if (this.player) {
  105. this._setDimensions();
  106. }
  107. }, 250, {
  108. trailing: true,
  109. });
  110. handlePlay = () => {
  111. this.setState({ paused: false });
  112. if (this.canvas && !this.audioContext) {
  113. this._initAudioContext();
  114. }
  115. if (this.audioContext && this.audioContext.state === 'suspended') {
  116. this.audioContext.resume();
  117. }
  118. this._renderCanvas();
  119. }
  120. handlePause = () => {
  121. this.setState({ paused: true });
  122. if (this.audioContext) {
  123. this.audioContext.suspend();
  124. }
  125. }
  126. handleProgress = () => {
  127. const lastTimeRange = this.audio.buffered.length - 1;
  128. if (lastTimeRange > -1) {
  129. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  130. }
  131. }
  132. toggleMute = () => {
  133. const muted = !this.state.muted;
  134. this.setState({ muted }, () => {
  135. this.audio.muted = muted;
  136. });
  137. }
  138. handleVolumeMouseDown = e => {
  139. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  140. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  141. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  142. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  143. this.handleMouseVolSlide(e);
  144. e.preventDefault();
  145. e.stopPropagation();
  146. }
  147. handleVolumeMouseUp = () => {
  148. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  149. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  150. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  151. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  152. }
  153. handleMouseDown = e => {
  154. document.addEventListener('mousemove', this.handleMouseMove, true);
  155. document.addEventListener('mouseup', this.handleMouseUp, true);
  156. document.addEventListener('touchmove', this.handleMouseMove, true);
  157. document.addEventListener('touchend', this.handleMouseUp, true);
  158. this.setState({ dragging: true });
  159. this.audio.pause();
  160. this.handleMouseMove(e);
  161. e.preventDefault();
  162. e.stopPropagation();
  163. }
  164. handleMouseUp = () => {
  165. document.removeEventListener('mousemove', this.handleMouseMove, true);
  166. document.removeEventListener('mouseup', this.handleMouseUp, true);
  167. document.removeEventListener('touchmove', this.handleMouseMove, true);
  168. document.removeEventListener('touchend', this.handleMouseUp, true);
  169. this.setState({ dragging: false });
  170. this.audio.play();
  171. }
  172. handleMouseMove = throttle(e => {
  173. const { x } = getPointerPosition(this.seek, e);
  174. const currentTime = this.audio.duration * x;
  175. if (!isNaN(currentTime)) {
  176. this.setState({ currentTime }, () => {
  177. this.audio.currentTime = currentTime;
  178. });
  179. }
  180. }, 15);
  181. handleTimeUpdate = () => {
  182. this.setState({
  183. currentTime: this.audio.currentTime,
  184. duration: Math.floor(this.audio.duration),
  185. });
  186. }
  187. handleMouseVolSlide = throttle(e => {
  188. const { x } = getPointerPosition(this.volume, e);
  189. if(!isNaN(x)) {
  190. this.setState({ volume: x }, () => {
  191. this.audio.volume = x;
  192. });
  193. }
  194. }, 15);
  195. handleScroll = throttle(() => {
  196. if (!this.canvas || !this.audio) {
  197. return;
  198. }
  199. const { top, height } = this.canvas.getBoundingClientRect();
  200. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  201. if (!this.state.paused && !inView) {
  202. this.setState({ paused: true }, () => this.audio.pause());
  203. }
  204. }, 150, { trailing: true });
  205. handleMouseEnter = () => {
  206. this.setState({ hovered: true });
  207. }
  208. handleMouseLeave = () => {
  209. this.setState({ hovered: false });
  210. }
  211. _initAudioContext () {
  212. const context = new AudioContext();
  213. const analyser = context.createAnalyser();
  214. const source = context.createMediaElementSource(this.audio);
  215. analyser.smoothingTimeConstant = 0.6;
  216. analyser.fftSize = 2048;
  217. source.connect(analyser);
  218. source.connect(context.destination);
  219. this.audioContext = context;
  220. this.analyser = analyser;
  221. }
  222. handleDownload = () => {
  223. fetch(this.props.src).then(res => res.blob()).then(blob => {
  224. const element = document.createElement('a');
  225. const objectURL = URL.createObjectURL(blob);
  226. element.setAttribute('href', objectURL);
  227. element.setAttribute('download', fileNameFromURL(this.props.src));
  228. document.body.appendChild(element);
  229. element.click();
  230. document.body.removeChild(element);
  231. URL.revokeObjectURL(objectURL);
  232. }).catch(err => {
  233. console.error(err);
  234. });
  235. }
  236. _renderCanvas () {
  237. requestAnimationFrame(() => {
  238. this.handleTimeUpdate();
  239. this._clear();
  240. this._draw();
  241. if (!this.state.paused) {
  242. this._renderCanvas();
  243. }
  244. });
  245. }
  246. _clear () {
  247. this.canvasContext.clearRect(0, 0, this.state.width, this.state.height);
  248. }
  249. _draw () {
  250. this.canvasContext.save();
  251. const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE);
  252. ticks.forEach(tick => {
  253. this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2);
  254. });
  255. this.canvasContext.restore();
  256. }
  257. _getRadius () {
  258. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  259. }
  260. _getScaleCoefficient () {
  261. return (this.state.height || this.props.height) / 982;
  262. }
  263. _getTicks (count, size, animationParams = [0, 90]) {
  264. const radius = this._getRadius();
  265. const ticks = this._getTickPoints(count);
  266. const lesser = 200;
  267. const m = [];
  268. const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
  269. const frequencyData = new Uint8Array(bufferLength);
  270. const allScales = [];
  271. const scaleCoefficient = this._getScaleCoefficient();
  272. if (this.analyser) {
  273. this.analyser.getByteFrequencyData(frequencyData);
  274. }
  275. ticks.forEach((tick, i) => {
  276. const coef = 1 - i / (ticks.length * 2.5);
  277. let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
  278. if (delta < 0) {
  279. delta = 0;
  280. }
  281. let k;
  282. if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) {
  283. k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta);
  284. } else {
  285. k = radius / (radius - (size + delta));
  286. }
  287. const x1 = tick.x * (radius - size);
  288. const y1 = tick.y * (radius - size);
  289. const x2 = x1 * k;
  290. const y2 = y1 * k;
  291. m.push({ x1, y1, x2, y2 });
  292. if (i < 20) {
  293. let scale = delta / (200 * scaleCoefficient);
  294. scale = scale < 1 ? 1 : scale;
  295. allScales.push(scale);
  296. }
  297. });
  298. const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
  299. return m.map(({ x1, y1, x2, y2 }) => ({
  300. x1: x1,
  301. y1: y1,
  302. x2: x2 * scale,
  303. y2: y2 * scale,
  304. }));
  305. }
  306. _getSize (angle, l, r) {
  307. const scaleCoefficient = this._getScaleCoefficient();
  308. const maxTickSize = TICK_SIZE * 9 * scaleCoefficient;
  309. const m = (r - l) / 2;
  310. const x = (angle - l);
  311. let h;
  312. if (x === m) {
  313. return maxTickSize;
  314. }
  315. const d = Math.abs(m - x);
  316. const v = 40 * Math.sqrt(1 / d);
  317. if (v > maxTickSize) {
  318. h = maxTickSize;
  319. } else {
  320. h = Math.max(TICK_SIZE, v);
  321. }
  322. return h;
  323. }
  324. _getTickPoints (count) {
  325. const PI = 360;
  326. const coords = [];
  327. const step = PI / count;
  328. let rad;
  329. for(let deg = 0; deg < PI; deg += step) {
  330. rad = deg * Math.PI / (PI / 2);
  331. coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg });
  332. }
  333. return coords;
  334. }
  335. _drawTick (x1, y1, x2, y2) {
  336. const cx = this._getCX();
  337. const cy = this._getCY();
  338. const dx1 = Math.ceil(cx + x1);
  339. const dy1 = Math.ceil(cy + y1);
  340. const dx2 = Math.ceil(cx + x2);
  341. const dy2 = Math.ceil(cy + y2);
  342. const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
  343. const mainColor = this._getAccentColor();
  344. const lastColor = hex2rgba(mainColor, 0);
  345. gradient.addColorStop(0, mainColor);
  346. gradient.addColorStop(0.6, mainColor);
  347. gradient.addColorStop(1, lastColor);
  348. this.canvasContext.beginPath();
  349. this.canvasContext.strokeStyle = gradient;
  350. this.canvasContext.lineWidth = 2;
  351. this.canvasContext.moveTo(dx1, dy1);
  352. this.canvasContext.lineTo(dx2, dy2);
  353. this.canvasContext.stroke();
  354. }
  355. _getCX() {
  356. return Math.floor(this.state.width / 2);
  357. }
  358. _getCY() {
  359. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  360. }
  361. _getAccentColor () {
  362. return this.props.accentColor || '#ffffff';
  363. }
  364. _getBackgroundColor () {
  365. return this.props.backgroundColor || '#000000';
  366. }
  367. _getForegroundColor () {
  368. return this.props.foregroundColor || '#ffffff';
  369. }
  370. render () {
  371. const { src, intl, alt, editable } = this.props;
  372. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  373. const progress = (currentTime / duration) * 100;
  374. return (
  375. <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}>
  376. <audio
  377. src={src}
  378. ref={this.setAudioRef}
  379. preload='none'
  380. onPlay={this.handlePlay}
  381. onPause={this.handlePause}
  382. onProgress={this.handleProgress}
  383. crossOrigin='anonymous'
  384. />
  385. <canvas
  386. role='button'
  387. className='audio-player__canvas'
  388. width={this.state.width}
  389. height={this.state.height}
  390. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  391. ref={this.setCanvasRef}
  392. onClick={this.togglePlay}
  393. title={alt}
  394. aria-label={alt}
  395. />
  396. <img
  397. src={this.props.poster}
  398. alt=''
  399. width={(this._getRadius() - TICK_SIZE) * 2}
  400. height={(this._getRadius() - TICK_SIZE) * 2}
  401. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  402. />
  403. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  404. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  405. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  406. <span
  407. className={classNames('video-player__seek__handle', { active: dragging })}
  408. tabIndex='0'
  409. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  410. />
  411. </div>
  412. <div className='video-player__controls active'>
  413. <div className='video-player__buttons-bar'>
  414. <div className='video-player__buttons left'>
  415. <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
  416. <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
  417. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  418. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  419. <span
  420. className={classNames('video-player__volume__handle')}
  421. tabIndex='0'
  422. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  423. />
  424. </div>
  425. <span className='video-player__time'>
  426. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  427. <span className='video-player__time-sep'>/</span>
  428. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  429. </span>
  430. </div>
  431. <div className='video-player__buttons right'>
  432. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  433. </div>
  434. </div>
  435. </div>
  436. </div>
  437. );
  438. }
  439. }