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.

486 lines
15 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  4. import { fromJS } from 'immutable';
  5. import { throttle } from 'lodash';
  6. import classNames from 'classnames';
  7. import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
  8. import { displayMedia } from '../../initial_state';
  9. import Icon from 'mastodon/components/icon';
  10. import { decode } from 'blurhash';
  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. hide: { id: 'video.hide', defaultMessage: 'Hide video' },
  17. expand: { id: 'video.expand', defaultMessage: 'Expand video' },
  18. close: { id: 'video.close', defaultMessage: 'Close video' },
  19. fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
  20. exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
  21. });
  22. const formatTime = secondsNum => {
  23. let hours = Math.floor(secondsNum / 3600);
  24. let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
  25. let seconds = secondsNum - (hours * 3600) - (minutes * 60);
  26. if (hours < 10) hours = '0' + hours;
  27. if (minutes < 10) minutes = '0' + minutes;
  28. if (seconds < 10) seconds = '0' + seconds;
  29. return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
  30. };
  31. export const findElementPosition = el => {
  32. let box;
  33. if (el.getBoundingClientRect && el.parentNode) {
  34. box = el.getBoundingClientRect();
  35. }
  36. if (!box) {
  37. return {
  38. left: 0,
  39. top: 0,
  40. };
  41. }
  42. const docEl = document.documentElement;
  43. const body = document.body;
  44. const clientLeft = docEl.clientLeft || body.clientLeft || 0;
  45. const scrollLeft = window.pageXOffset || body.scrollLeft;
  46. const left = (box.left + scrollLeft) - clientLeft;
  47. const clientTop = docEl.clientTop || body.clientTop || 0;
  48. const scrollTop = window.pageYOffset || body.scrollTop;
  49. const top = (box.top + scrollTop) - clientTop;
  50. return {
  51. left: Math.round(left),
  52. top: Math.round(top),
  53. };
  54. };
  55. export const getPointerPosition = (el, event) => {
  56. const position = {};
  57. const box = findElementPosition(el);
  58. const boxW = el.offsetWidth;
  59. const boxH = el.offsetHeight;
  60. const boxY = box.top;
  61. const boxX = box.left;
  62. let pageY = event.pageY;
  63. let pageX = event.pageX;
  64. if (event.changedTouches) {
  65. pageX = event.changedTouches[0].pageX;
  66. pageY = event.changedTouches[0].pageY;
  67. }
  68. position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
  69. position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
  70. return position;
  71. };
  72. export default @injectIntl
  73. class Video extends React.PureComponent {
  74. static propTypes = {
  75. preview: PropTypes.string,
  76. src: PropTypes.string.isRequired,
  77. alt: PropTypes.string,
  78. width: PropTypes.number,
  79. height: PropTypes.number,
  80. sensitive: PropTypes.bool,
  81. startTime: PropTypes.number,
  82. onOpenVideo: PropTypes.func,
  83. onCloseVideo: PropTypes.func,
  84. detailed: PropTypes.bool,
  85. inline: PropTypes.bool,
  86. cacheWidth: PropTypes.func,
  87. intl: PropTypes.object.isRequired,
  88. blurhash: PropTypes.string,
  89. };
  90. state = {
  91. currentTime: 0,
  92. duration: 0,
  93. volume: 0.5,
  94. paused: true,
  95. dragging: false,
  96. containerWidth: this.props.width,
  97. fullscreen: false,
  98. hovered: false,
  99. muted: false,
  100. revealed: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all',
  101. };
  102. // hard coded in components.scss
  103. // any way to get ::before values programatically?
  104. volWidth = 50;
  105. volOffset = 70;
  106. volHandleOffset = v => {
  107. const offset = v * this.volWidth + this.volOffset;
  108. return (offset > 110) ? 110 : offset;
  109. }
  110. setPlayerRef = c => {
  111. this.player = c;
  112. if (c) {
  113. if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
  114. this.setState({
  115. containerWidth: c.offsetWidth,
  116. });
  117. }
  118. }
  119. setVideoRef = c => {
  120. this.video = c;
  121. if (this.video) {
  122. this.setState({ volume: this.video.volume, muted: this.video.muted });
  123. }
  124. }
  125. setSeekRef = c => {
  126. this.seek = c;
  127. }
  128. setVolumeRef = c => {
  129. this.volume = c;
  130. }
  131. setCanvasRef = c => {
  132. this.canvas = c;
  133. }
  134. handleClickRoot = e => e.stopPropagation();
  135. handlePlay = () => {
  136. this.setState({ paused: false });
  137. }
  138. handlePause = () => {
  139. this.setState({ paused: true });
  140. }
  141. handleTimeUpdate = () => {
  142. this.setState({
  143. currentTime: Math.floor(this.video.currentTime),
  144. duration: Math.floor(this.video.duration),
  145. });
  146. }
  147. handleVolumeMouseDown = e => {
  148. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  149. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  150. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  151. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  152. this.handleMouseVolSlide(e);
  153. e.preventDefault();
  154. e.stopPropagation();
  155. }
  156. handleVolumeMouseUp = () => {
  157. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  158. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  159. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  160. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  161. }
  162. handleMouseVolSlide = throttle(e => {
  163. const rect = this.volume.getBoundingClientRect();
  164. const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
  165. if(!isNaN(x)) {
  166. var slideamt = x;
  167. if(x > 1) {
  168. slideamt = 1;
  169. } else if(x < 0) {
  170. slideamt = 0;
  171. }
  172. this.video.volume = slideamt;
  173. this.setState({ volume: slideamt });
  174. }
  175. }, 60);
  176. handleMouseDown = e => {
  177. document.addEventListener('mousemove', this.handleMouseMove, true);
  178. document.addEventListener('mouseup', this.handleMouseUp, true);
  179. document.addEventListener('touchmove', this.handleMouseMove, true);
  180. document.addEventListener('touchend', this.handleMouseUp, true);
  181. this.setState({ dragging: true });
  182. this.video.pause();
  183. this.handleMouseMove(e);
  184. e.preventDefault();
  185. e.stopPropagation();
  186. }
  187. handleMouseUp = () => {
  188. document.removeEventListener('mousemove', this.handleMouseMove, true);
  189. document.removeEventListener('mouseup', this.handleMouseUp, true);
  190. document.removeEventListener('touchmove', this.handleMouseMove, true);
  191. document.removeEventListener('touchend', this.handleMouseUp, true);
  192. this.setState({ dragging: false });
  193. this.video.play();
  194. }
  195. handleMouseMove = throttle(e => {
  196. const { x } = getPointerPosition(this.seek, e);
  197. const currentTime = Math.floor(this.video.duration * x);
  198. if (!isNaN(currentTime)) {
  199. this.video.currentTime = currentTime;
  200. this.setState({ currentTime });
  201. }
  202. }, 60);
  203. togglePlay = () => {
  204. if (this.state.paused) {
  205. this.video.play();
  206. } else {
  207. this.video.pause();
  208. }
  209. }
  210. toggleFullscreen = () => {
  211. if (isFullscreen()) {
  212. exitFullscreen();
  213. } else {
  214. requestFullscreen(this.player);
  215. }
  216. }
  217. componentDidMount () {
  218. document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
  219. document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
  220. document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
  221. document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
  222. if (this.props.blurhash) {
  223. this._decode();
  224. }
  225. }
  226. componentWillUnmount () {
  227. document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
  228. document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
  229. document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
  230. document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
  231. }
  232. componentDidUpdate (prevProps) {
  233. if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
  234. this._decode();
  235. }
  236. }
  237. _decode () {
  238. const hash = this.props.blurhash;
  239. const pixels = decode(hash, 32, 32);
  240. if (pixels) {
  241. const ctx = this.canvas.getContext('2d');
  242. const imageData = new ImageData(pixels, 32, 32);
  243. ctx.putImageData(imageData, 0, 0);
  244. }
  245. }
  246. handleFullscreenChange = () => {
  247. this.setState({ fullscreen: isFullscreen() });
  248. }
  249. handleMouseEnter = () => {
  250. this.setState({ hovered: true });
  251. }
  252. handleMouseLeave = () => {
  253. this.setState({ hovered: false });
  254. }
  255. toggleMute = () => {
  256. this.video.muted = !this.video.muted;
  257. this.setState({ muted: this.video.muted });
  258. }
  259. toggleReveal = () => {
  260. if (this.state.revealed) {
  261. this.video.pause();
  262. }
  263. this.setState({ revealed: !this.state.revealed });
  264. }
  265. handleLoadedData = () => {
  266. if (this.props.startTime) {
  267. this.video.currentTime = this.props.startTime;
  268. this.video.play();
  269. }
  270. }
  271. handleProgress = () => {
  272. if (this.video.buffered.length > 0) {
  273. this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
  274. }
  275. }
  276. handleVolumeChange = () => {
  277. this.setState({ volume: this.video.volume, muted: this.video.muted });
  278. }
  279. handleOpenVideo = () => {
  280. const { src, preview, width, height, alt } = this.props;
  281. const media = fromJS({
  282. type: 'video',
  283. url: src,
  284. preview_url: preview,
  285. description: alt,
  286. width,
  287. height,
  288. });
  289. this.video.pause();
  290. this.props.onOpenVideo(media, this.video.currentTime);
  291. }
  292. handleCloseVideo = () => {
  293. this.video.pause();
  294. this.props.onCloseVideo();
  295. }
  296. render () {
  297. const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
  298. const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
  299. const progress = (currentTime / duration) * 100;
  300. const volumeWidth = (muted) ? 0 : volume * this.volWidth;
  301. const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
  302. const playerStyle = {};
  303. let { width, height } = this.props;
  304. if (inline && containerWidth) {
  305. width = containerWidth;
  306. height = containerWidth / (16/9);
  307. playerStyle.height = height;
  308. }
  309. let preload;
  310. if (startTime || fullscreen || dragging) {
  311. preload = 'auto';
  312. } else if (detailed) {
  313. preload = 'metadata';
  314. } else {
  315. preload = 'none';
  316. }
  317. let warning;
  318. if (sensitive) {
  319. warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
  320. } else {
  321. warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
  322. }
  323. return (
  324. <div
  325. role='menuitem'
  326. className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })}
  327. style={playerStyle}
  328. ref={this.setPlayerRef}
  329. onMouseEnter={this.handleMouseEnter}
  330. onMouseLeave={this.handleMouseLeave}
  331. onClick={this.handleClickRoot}
  332. tabIndex={0}
  333. >
  334. <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
  335. {revealed && <video
  336. ref={this.setVideoRef}
  337. src={src}
  338. poster={preview}
  339. preload={preload}
  340. loop
  341. role='button'
  342. tabIndex='0'
  343. aria-label={alt}
  344. title={alt}
  345. width={width}
  346. height={height}
  347. volume={volume}
  348. onClick={this.togglePlay}
  349. onPlay={this.handlePlay}
  350. onPause={this.handlePause}
  351. onTimeUpdate={this.handleTimeUpdate}
  352. onLoadedData={this.handleLoadedData}
  353. onProgress={this.handleProgress}
  354. onVolumeChange={this.handleVolumeChange}
  355. />}
  356. <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
  357. <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
  358. <span className='spoiler-button__overlay__label'>{warning}</span>
  359. </button>
  360. </div>
  361. <div className={classNames('video-player__controls', { active: paused || hovered })}>
  362. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  363. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  364. <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
  365. <span
  366. className={classNames('video-player__seek__handle', { active: dragging })}
  367. tabIndex='0'
  368. style={{ left: `${progress}%` }}
  369. />
  370. </div>
  371. <div className='video-player__buttons-bar'>
  372. <div className='video-player__buttons left'>
  373. <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
  374. <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
  375. <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
  376. <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
  377. <span
  378. className={classNames('video-player__volume__handle')}
  379. tabIndex='0'
  380. style={{ left: `${volumeHandleLoc}px` }}
  381. />
  382. </div>
  383. {(detailed || fullscreen) &&
  384. <span>
  385. <span className='video-player__time-current'>{formatTime(currentTime)}</span>
  386. <span className='video-player__time-sep'>/</span>
  387. <span className='video-player__time-total'>{formatTime(duration)}</span>
  388. </span>
  389. }
  390. </div>
  391. <div className='video-player__buttons right'>
  392. {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
  393. {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
  394. {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
  395. <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
  396. </div>
  397. </div>
  398. </div>
  399. </div>
  400. );
  401. }
  402. }