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.

418 lines
13 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. };
  37. state = {
  38. width: this.props.width,
  39. currentTime: 0,
  40. buffer: 0,
  41. duration: null,
  42. paused: true,
  43. muted: false,
  44. volume: 0.5,
  45. dragging: false,
  46. };
  47. constructor (props) {
  48. super(props);
  49. this.visualizer = new Visualizer(TICK_SIZE);
  50. }
  51. setPlayerRef = c => {
  52. this.player = c;
  53. if (this.player) {
  54. this._setDimensions();
  55. }
  56. }
  57. _setDimensions () {
  58. const width = this.player.offsetWidth;
  59. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  60. if (this.props.cacheWidth) {
  61. this.props.cacheWidth(width);
  62. }
  63. this.setState({ width, height });
  64. }
  65. setSeekRef = c => {
  66. this.seek = c;
  67. }
  68. setVolumeRef = c => {
  69. this.volume = c;
  70. }
  71. setAudioRef = c => {
  72. this.audio = c;
  73. if (this.audio) {
  74. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  75. }
  76. }
  77. setCanvasRef = c => {
  78. this.canvas = c;
  79. this.visualizer.setCanvas(c);
  80. }
  81. componentDidMount () {
  82. window.addEventListener('scroll', this.handleScroll);
  83. window.addEventListener('resize', this.handleResize, { passive: true });
  84. }
  85. componentDidUpdate (prevProps, prevState) {
  86. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
  87. this._clear();
  88. this._draw();
  89. }
  90. }
  91. componentWillUnmount () {
  92. window.removeEventListener('scroll', this.handleScroll);
  93. window.removeEventListener('resize', this.handleResize);
  94. }
  95. togglePlay = () => {
  96. if (this.state.paused) {
  97. this.setState({ paused: false }, () => this.audio.play());
  98. } else {
  99. this.setState({ paused: true }, () => this.audio.pause());
  100. }
  101. }
  102. handleResize = debounce(() => {
  103. if (this.player) {
  104. this._setDimensions();
  105. }
  106. }, 250, {
  107. trailing: true,
  108. });
  109. handlePlay = () => {
  110. this.setState({ paused: false });
  111. if (this.canvas && !this.audioContext) {
  112. this._initAudioContext();
  113. }
  114. if (this.audioContext && this.audioContext.state === 'suspended') {
  115. this.audioContext.resume();
  116. }
  117. this._renderCanvas();
  118. }
  119. handlePause = () => {
  120. this.setState({ paused: true });
  121. if (this.audioContext) {
  122. this.audioContext.suspend();
  123. }
  124. }
  125. handleProgress = () => {
  126. const lastTimeRange = this.audio.buffered.length - 1;
  127. if (lastTimeRange > -1) {
  128. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  129. }
  130. }
  131. toggleMute = () => {
  132. const muted = !this.state.muted;
  133. this.setState({ muted }, () => {
  134. this.audio.muted = muted;
  135. });
  136. }
  137. handleVolumeMouseDown = e => {
  138. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  139. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  140. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  141. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  142. this.handleMouseVolSlide(e);
  143. e.preventDefault();
  144. e.stopPropagation();
  145. }
  146. handleVolumeMouseUp = () => {
  147. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  148. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  149. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  150. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  151. }
  152. handleMouseDown = e => {
  153. document.addEventListener('mousemove', this.handleMouseMove, true);
  154. document.addEventListener('mouseup', this.handleMouseUp, true);
  155. document.addEventListener('touchmove', this.handleMouseMove, true);
  156. document.addEventListener('touchend', this.handleMouseUp, true);
  157. this.setState({ dragging: true });
  158. this.audio.pause();
  159. this.handleMouseMove(e);
  160. e.preventDefault();
  161. e.stopPropagation();
  162. }
  163. handleMouseUp = () => {
  164. document.removeEventListener('mousemove', this.handleMouseMove, true);
  165. document.removeEventListener('mouseup', this.handleMouseUp, true);
  166. document.removeEventListener('touchmove', this.handleMouseMove, true);
  167. document.removeEventListener('touchend', this.handleMouseUp, true);
  168. this.setState({ dragging: false });
  169. this.audio.play();
  170. }
  171. handleMouseMove = throttle(e => {
  172. const { x } = getPointerPosition(this.seek, e);
  173. const currentTime = this.audio.duration * x;
  174. if (!isNaN(currentTime)) {
  175. this.setState({ currentTime }, () => {
  176. this.audio.currentTime = currentTime;
  177. });
  178. }
  179. }, 15);
  180. handleTimeUpdate = () => {
  181. this.setState({
  182. currentTime: this.audio.currentTime,
  183. duration: Math.floor(this.audio.duration),
  184. });
  185. }
  186. handleMouseVolSlide = throttle(e => {
  187. const { x } = getPointerPosition(this.volume, e);
  188. if(!isNaN(x)) {
  189. this.setState({ volume: x }, () => {
  190. this.audio.volume = x;
  191. });
  192. }
  193. }, 15);
  194. handleScroll = throttle(() => {
  195. if (!this.canvas || !this.audio) {
  196. return;
  197. }
  198. const { top, height } = this.canvas.getBoundingClientRect();
  199. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  200. if (!this.state.paused && !inView) {
  201. this.setState({ paused: true }, () => this.audio.pause());
  202. }
  203. }, 150, { trailing: true });
  204. handleMouseEnter = () => {
  205. this.setState({ hovered: true });
  206. }
  207. handleMouseLeave = () => {
  208. this.setState({ hovered: false });
  209. }
  210. _initAudioContext () {
  211. const context = new AudioContext();
  212. const source = context.createMediaElementSource(this.audio);
  213. this.visualizer.setAudioContext(context, source);
  214. source.connect(context.destination);
  215. this.audioContext = context;
  216. }
  217. handleDownload = () => {
  218. fetch(this.props.src).then(res => res.blob()).then(blob => {
  219. const element = document.createElement('a');
  220. const objectURL = URL.createObjectURL(blob);
  221. element.setAttribute('href', objectURL);
  222. element.setAttribute('download', fileNameFromURL(this.props.src));
  223. document.body.appendChild(element);
  224. element.click();
  225. document.body.removeChild(element);
  226. URL.revokeObjectURL(objectURL);
  227. }).catch(err => {
  228. console.error(err);
  229. });
  230. }
  231. _renderCanvas () {
  232. requestAnimationFrame(() => {
  233. this.handleTimeUpdate();
  234. this._clear();
  235. this._draw();
  236. if (!this.state.paused) {
  237. this._renderCanvas();
  238. }
  239. });
  240. }
  241. _clear() {
  242. this.visualizer.clear(this.state.width, this.state.height);
  243. }
  244. _draw() {
  245. this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
  246. }
  247. _getRadius () {
  248. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  249. }
  250. _getScaleCoefficient () {
  251. return (this.state.height || this.props.height) / 982;
  252. }
  253. _getCX() {
  254. return Math.floor(this.state.width / 2);
  255. }
  256. _getCY() {
  257. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  258. }
  259. _getAccentColor () {
  260. return this.props.accentColor || '#ffffff';
  261. }
  262. _getBackgroundColor () {
  263. return this.props.backgroundColor || '#000000';
  264. }
  265. _getForegroundColor () {
  266. return this.props.foregroundColor || '#ffffff';
  267. }
  268. render () {
  269. const { src, intl, alt, editable } = this.props;
  270. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  271. const progress = (currentTime / duration) * 100;
  272. return (
  273. <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}>
  274. <audio
  275. src={src}
  276. ref={this.setAudioRef}
  277. preload='none'
  278. onPlay={this.handlePlay}
  279. onPause={this.handlePause}
  280. onProgress={this.handleProgress}
  281. crossOrigin='anonymous'
  282. />
  283. <canvas
  284. role='button'
  285. className='audio-player__canvas'
  286. width={this.state.width}
  287. height={this.state.height}
  288. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  289. ref={this.setCanvasRef}
  290. onClick={this.togglePlay}
  291. title={alt}
  292. aria-label={alt}
  293. />
  294. <img
  295. src={this.props.poster}
  296. alt=''
  297. width={(this._getRadius() - TICK_SIZE) * 2}
  298. height={(this._getRadius() - TICK_SIZE) * 2}
  299. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  300. />
  301. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  302. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  303. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  304. <span
  305. className={classNames('video-player__seek__handle', { active: dragging })}
  306. tabIndex='0'
  307. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  308. />
  309. </div>
  310. <div className='video-player__controls active'>
  311. <div className='video-player__buttons-bar'>
  312. <div className='video-player__buttons left'>
  313. <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>
  314. <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>
  315. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  316. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  317. <span
  318. className={classNames('video-player__volume__handle')}
  319. tabIndex='0'
  320. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  321. />
  322. </div>
  323. <span className='video-player__time'>
  324. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  325. <span className='video-player__time-sep'>/</span>
  326. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  327. </span>
  328. </div>
  329. <div className='video-player__buttons right'>
  330. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  331. </div>
  332. </div>
  333. </div>
  334. </div>
  335. );
  336. }
  337. }