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.

428 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. autoPlay: PropTypes.bool,
  37. };
  38. state = {
  39. width: this.props.width,
  40. currentTime: 0,
  41. buffer: 0,
  42. duration: null,
  43. paused: true,
  44. muted: false,
  45. volume: 0.5,
  46. dragging: false,
  47. };
  48. constructor (props) {
  49. super(props);
  50. this.visualizer = new Visualizer(TICK_SIZE);
  51. }
  52. setPlayerRef = c => {
  53. this.player = c;
  54. if (this.player) {
  55. this._setDimensions();
  56. }
  57. }
  58. _setDimensions () {
  59. const width = this.player.offsetWidth;
  60. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  61. if (this.props.cacheWidth) {
  62. this.props.cacheWidth(width);
  63. }
  64. this.setState({ width, height });
  65. }
  66. setSeekRef = c => {
  67. this.seek = c;
  68. }
  69. setVolumeRef = c => {
  70. this.volume = c;
  71. }
  72. setAudioRef = c => {
  73. this.audio = c;
  74. if (this.audio) {
  75. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  76. }
  77. }
  78. setCanvasRef = c => {
  79. this.canvas = c;
  80. this.visualizer.setCanvas(c);
  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 || prevProps.accentColor !== this.props.accentColor) {
  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. handleLoadedData = () => {
  212. const { autoPlay } = this.props;
  213. if (autoPlay) {
  214. this.audio.play();
  215. }
  216. }
  217. _initAudioContext () {
  218. const context = new AudioContext();
  219. const source = context.createMediaElementSource(this.audio);
  220. this.visualizer.setAudioContext(context, source);
  221. source.connect(context.destination);
  222. this.audioContext = context;
  223. }
  224. handleDownload = () => {
  225. fetch(this.props.src).then(res => res.blob()).then(blob => {
  226. const element = document.createElement('a');
  227. const objectURL = URL.createObjectURL(blob);
  228. element.setAttribute('href', objectURL);
  229. element.setAttribute('download', fileNameFromURL(this.props.src));
  230. document.body.appendChild(element);
  231. element.click();
  232. document.body.removeChild(element);
  233. URL.revokeObjectURL(objectURL);
  234. }).catch(err => {
  235. console.error(err);
  236. });
  237. }
  238. _renderCanvas () {
  239. requestAnimationFrame(() => {
  240. this.handleTimeUpdate();
  241. this._clear();
  242. this._draw();
  243. if (!this.state.paused) {
  244. this._renderCanvas();
  245. }
  246. });
  247. }
  248. _clear() {
  249. this.visualizer.clear(this.state.width, this.state.height);
  250. }
  251. _draw() {
  252. this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
  253. }
  254. _getRadius () {
  255. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  256. }
  257. _getScaleCoefficient () {
  258. return (this.state.height || this.props.height) / 982;
  259. }
  260. _getCX() {
  261. return Math.floor(this.state.width / 2);
  262. }
  263. _getCY() {
  264. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  265. }
  266. _getAccentColor () {
  267. return this.props.accentColor || '#ffffff';
  268. }
  269. _getBackgroundColor () {
  270. return this.props.backgroundColor || '#000000';
  271. }
  272. _getForegroundColor () {
  273. return this.props.foregroundColor || '#ffffff';
  274. }
  275. render () {
  276. const { src, intl, alt, editable, autoPlay } = this.props;
  277. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  278. const progress = (currentTime / duration) * 100;
  279. return (
  280. <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}>
  281. <audio
  282. src={src}
  283. ref={this.setAudioRef}
  284. preload={autoPlay ? 'auto' : 'none'}
  285. onPlay={this.handlePlay}
  286. onPause={this.handlePause}
  287. onProgress={this.handleProgress}
  288. onLoadedData={this.handleLoadedData}
  289. crossOrigin='anonymous'
  290. />
  291. <canvas
  292. role='button'
  293. className='audio-player__canvas'
  294. width={this.state.width}
  295. height={this.state.height}
  296. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  297. ref={this.setCanvasRef}
  298. onClick={this.togglePlay}
  299. title={alt}
  300. aria-label={alt}
  301. />
  302. <img
  303. src={this.props.poster}
  304. alt=''
  305. width={(this._getRadius() - TICK_SIZE) * 2}
  306. height={(this._getRadius() - TICK_SIZE) * 2}
  307. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  308. />
  309. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  310. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  311. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  312. <span
  313. className={classNames('video-player__seek__handle', { active: dragging })}
  314. tabIndex='0'
  315. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  316. />
  317. </div>
  318. <div className='video-player__controls active'>
  319. <div className='video-player__buttons-bar'>
  320. <div className='video-player__buttons left'>
  321. <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>
  322. <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>
  323. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  324. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  325. <span
  326. className={classNames('video-player__volume__handle')}
  327. tabIndex='0'
  328. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  329. />
  330. </div>
  331. <span className='video-player__time'>
  332. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  333. <span className='video-player__time-sep'>/</span>
  334. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  335. </span>
  336. </div>
  337. <div className='video-player__buttons right'>
  338. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  339. </div>
  340. </div>
  341. </div>
  342. </div>
  343. );
  344. }
  345. }