import ImmutablePropTypes from 'react-immutable-proptypes' import ImmutablePureComponent from 'react-immutable-pure-component' import { defineMessages, injectIntl } from 'react-intl' import { is } from 'immutable' import throttle from 'lodash.throttle' import { decode } from 'blurhash' import { isFullscreen, requestFullscreen, exitFullscreen } from '../utils/fullscreen' import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio' import { openPopover, } from '../actions/popover' import { displayMedia } from '../initial_state' import { CX, POPOVER_VIDEO_STATS, BREAKPOINT_EXTRA_SMALL, } from '../constants' import Responsive from '../features/ui/util/responsive_component' import Button from './button' import Icon from './icon' import SensitiveMediaItem from './sensitive_media_item' import Text from './text' // check every 50 ms for buffer const checkInterval = 50 const FIXED_VAR = 6 const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, pause: { id: 'video.pause', defaultMessage: 'Pause' }, mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, hide: { id: 'video.hide', defaultMessage: 'Hide video' }, fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, sensitive: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' }, hidden: { id: 'status.media_hidden', defaultMessage: 'Media hidden' }, video_stats: { id: 'video.stats_label', defaultMessage: 'Video meta stats' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' }, }) const formatTime = (secondsNum) => { let hours = Math.floor(secondsNum / 3600) let minutes = Math.floor((secondsNum - (hours * 3600)) / 60) let seconds = Math.floor(secondsNum) - (hours * 3600) - (minutes * 60) if (hours < 10) hours = '0' + hours if (minutes < 10) minutes = '0' + minutes if (seconds < 10) seconds = '0' + seconds return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}` } export const findElementPosition = (el) => { let box if (el.getBoundingClientRect && el.parentNode) { box = el.getBoundingClientRect() } if (!box) { return { left: 0, top: 0, } } const docEl = document.documentElement const body = document.body const clientLeft = docEl.clientLeft || body.clientLeft || 0 const scrollLeft = window.pageXOffset || body.scrollLeft const left = (box.left + scrollLeft) - clientLeft const clientTop = docEl.clientTop || body.clientTop || 0 const scrollTop = window.pageYOffset || body.scrollTop const top = (box.top + scrollTop) - clientTop return { left: Math.round(left), top: Math.round(top), } } export const getPointerPosition = (el, event) => { const position = {} const box = findElementPosition(el) const boxW = el.offsetWidth const boxH = el.offsetHeight const boxY = box.top const boxX = box.left let pageY = event.pageY let pageX = event.pageX if (event.changedTouches) { pageX = event.changedTouches[0].pageX pageY = event.changedTouches[0].pageY } position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)) position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)) return position } const mapDispatchToProps = (dispatch) => ({ onOpenVideoStatsPopover(targetRef, meta) { dispatch(openPopover(POPOVER_VIDEO_STATS, { targetRef, meta, position: 'top', })) } }) export default @injectIntl @connect(null, mapDispatchToProps) class Video extends ImmutablePureComponent { static propTypes = { preview: PropTypes.string, src: PropTypes.string.isRequired, alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, startTime: PropTypes.number, detailed: PropTypes.bool, inline: PropTypes.bool, cacheWidth: PropTypes.func, visible: PropTypes.bool, onToggleVisibility: PropTypes.func, intl: PropTypes.object.isRequired, blurhash: PropTypes.string, aspectRatio: PropTypes.number, meta: ImmutablePropTypes.map, onOpenVideoStatsPopover: PropTypes.func.isRequired, } state = { currentTime: 0, duration: 0, volume: 0.5, paused: true, dragging: false, draggingVolume: false, containerWidth: this.props.width, fullscreen: false, hovered: false, muted: false, hoveringVolumeButton: false, hoveringVolumeControl: false, revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'), pipAvailable: true, isBuffering: false, } bufferCheckInterval = null lastPlayPos = 0 volHeight = 100 volOffset = 13 componentDidMount() { const { meta, blurhash } = this.props document.addEventListener('fullscreenchange', this.handleFullscreenChange, true) document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true) document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true) document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true) if (meta) { this.setState({ duration: parseInt(meta.get('duration')) }) } if ('pictureInPictureEnabled' in document) { this.setState({ pipAvailable: true }) } } componentWillUnmount() { document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true) document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true) document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true) document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true) clearInterval(this.bufferCheckInterval) } componentWillReceiveProps(nextProps) { if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { this.setState({ revealed: nextProps.visible }) } } componentDidUpdate(prevProps, prevState) { if (prevState.revealed && !this.state.revealed && this.video) { this.video.pause() } } checkBuffering = () => { const { isBuffering, paused } = this.state if (!this.video) { this.handlePause() return } const { currentTime } = this.video // Checking offset should be at most the check interval but allow for some margin let offset = (checkInterval - 30) / 1000 if (!isBuffering && currentTime < (this.lastPlayPos + offset) && !paused) { // If no buffering is currently detected, and the position does not seem to increase // and the player isn't manually paused... this.setState({ isBuffering: true }) } else if (isBuffering && currentTime > (this.lastPlayPos + offset) && !paused) { // If we were buffering but the player has advanced, then there is no buffering this.setState({ isBuffering: false }) } this.lastPlayPos = currentTime } volHandleOffset = (v) => { const offset = v * this.volHeight + this.volOffset return (offset > 110) ? 110 : offset } setPlayerRef = (n) => { this.player = n if (n) { if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth) this.setState({ containerWidth: n.offsetWidth, }) } } setVideoRef = (n) => { this.video = n if (this.video) { const { volume, muted } = this.video this.setState({ volume, muted, }) } } setSeekRef = (n) => { this.seek = n } setVolumeRef = (n) => { this.volume = n } setSettingsBtnRef = (n) => { this.settingsBtn = n } handleClickRoot = (e) => e.stopPropagation() handlePlay = () => { this.setState({ paused: false }) this.bufferCheckInterval = setInterval(this.checkBuffering, checkInterval) } handlePause = () => { this.setState({ paused: true, isBuffering: false, }) clearInterval(this.bufferCheckInterval) } handleTimeUpdate = () => { const { currentTime, duration } = this.video this.setState({ currentTime: currentTime.toFixed(FIXED_VAR), duration: duration.toFixed(FIXED_VAR), }) } handleVolumeMouseDown = (e) => { document.addEventListener('mousemove', this.handleMouseVolSlide, true) document.addEventListener('mouseup', this.handleVolumeMouseUp, true) document.addEventListener('touchmove', this.handleMouseVolSlide, true) document.addEventListener('touchend', this.handleVolumeMouseUp, true) this.handleMouseVolSlide(e) e.preventDefault() e.stopPropagation() this.setState({ draggingVolume: true }) } handleVolumeMouseUp = () => { this.handleMouseLeaveVolumeControl() document.removeEventListener('mousemove', this.handleMouseVolSlide, true) document.removeEventListener('mouseup', this.handleVolumeMouseUp, true) document.removeEventListener('touchmove', this.handleMouseVolSlide, true) document.removeEventListener('touchend', this.handleVolumeMouseUp, true) this.setState({ draggingVolume: false }) } handleMouseVolSlide = throttle((e) => { const rect = this.volume.getBoundingClientRect() const y = 1 - ((e.clientY - rect.top) / this.volHeight) if (!isNaN(y)) { const slideamt = y if (y > 1) { slideamt = 1 } else if (y < 0) { slideamt = 0 } this.video.volume = slideamt this.setState({ volume: slideamt }) } }, 60) handleMouseDown = (e) => { document.addEventListener('mousemove', this.handleMouseMove, true) document.addEventListener('mouseup', this.handleMouseUp, true) document.addEventListener('touchmove', this.handleMouseMove, true) document.addEventListener('touchend', this.handleMouseUp, true) this.setState({ dragging: true }) this.video.pause() this.handleMouseMove(e) e.preventDefault() e.stopPropagation() } handleMouseUp = () => { document.removeEventListener('mousemove', this.handleMouseMove, true) document.removeEventListener('mouseup', this.handleMouseUp, true) document.removeEventListener('touchmove', this.handleMouseMove, true) document.removeEventListener('touchend', this.handleMouseUp, true) this.setState({ dragging: false }) this.video.play() } handleMouseMove = throttle(e => { const { x } = getPointerPosition(this.seek, e) const currentTime = parseFloat(this.video.duration * x).toFixed(FIXED_VAR) if (!isNaN(currentTime)) { this.video.currentTime = currentTime this.setState({ currentTime }) } }, 60) togglePlay = () => { if (this.state.paused) { this.video.play() } else { this.video.pause() } } toggleFullscreen = () => { if (isFullscreen()) { exitFullscreen() } else { requestFullscreen(this.player) } } togglePip = () => { try { if (this.video !== document.pictureInPictureElement) { if (this.state.paused) { this.video.play() } setTimeout(() => { // : hack : this.video.requestPictureInPicture() }, 500) } else { document.exitPictureInPicture() } } catch(e) { // } } handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }) } handleMouseEnter = () => { this.setState({ hovered: true }) } handleMouseLeave = () => { this.setState({ hovered: false }) } handleMouseEnterAudio = () => { this.setState({ hoveringVolumeButton: true }) } handleMouseLeaveAudio = throttle(() => { this.setState({ hoveringVolumeButton: false }) }, 2500) handleMouseEnterVolumeControl = () => { this.setState({ hoveringVolumeControl: true }) } handleMouseLeaveVolumeControl = throttle(() => { if (!this.state.draggingVolume) { this.setState({ hoveringVolumeControl: false }) } }, 2500) toggleMute = () => { this.video.muted = !this.video.muted this.setState({ muted: this.video.muted }) } toggleReveal = () => { if (this.props.onToggleVisibility) { this.props.onToggleVisibility() } else { this.setState({ revealed: !this.state.revealed }) } } handleLoadedData = () => { if (this.props.startTime) { this.video.currentTime = this.props.startTime this.video.play() } } handleProgress = () => { const { buffered, duration } = this.video if (!buffered) return if (buffered.length > 0) { this.setState({ buffer: buffered.end(0) / duration * 100, }) } } handleVolumeChange = () => { const { volume, muted } = this.video this.setState({ volume, muted, }) } handleOnClickSettings = () => { this.props.onOpenVideoStatsPopover(this.settingsBtn, this.props.meta) } render() { const { preview, src, inline, startTime, intl, alt, detailed, sensitive, aspectRatio, } = this.props const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed, hoveringVolumeButton, hoveringVolumeControl, pipAvailable, isBuffering, } = this.state const progress = (currentTime / duration) * 100 const volumeHeight = (muted) ? 0 : volume * this.volHeight const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume) const playerStyle = {} let { width, height } = this.props if (inline && containerWidth) { width = containerWidth const minSize = containerWidth / (16 / 9) if (isPanoramic(aspectRatio)) { height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize) } else if (isPortrait(aspectRatio)) { height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize) } else { height = Math.floor(containerWidth / aspectRatio) } playerStyle.height = height } let preload if (startTime || fullscreen || dragging) { preload = 'auto' } else if (detailed) { preload = 'metadata' } else { preload = 'none' } // className={classNames('video-player', { // inactive: !revealed, // detailed, // inline: inline && !fullscreen, // fullscreen // })} // : todo spoiler : const mainContainerClasses = CX({ default: 1, mt10: 1, outlineNone: 1, }) const seekHandleClasses = CX({ default: 1, posAbs: 1, circle: 1, height20PX: 1, width20PX: 1, bgTransparent: 1, mlNeg5PX: 1, mr5: 1, z3: 1, alignItemsCenter: 1, justifyContentCenter: 1, videoEase: 1, opacity0: !dragging, opacity1: dragging || hovered, }) const seekInnerHandleClasses = CX({ default: 1, circle: 1, height14PX: 1, width14PX: 1, bgBrand: 1, boxShadow1: 1, }) const progressClasses = CX({ default: 1, radiusSmall: 1, mt10: 1, posAbs: 1, height4PX: 1, videoEase: 1, }) const volumeControlClasses = CX({ default: 1, posAbs: 1, bgBlackOpaque: 1, videoPlayerVolume: 1, height122PX: 1, circle: 1, displayNone: !hoveringVolumeButton && !hoveringVolumeControl || !hovered, }) const videoControlsBackgroundClasses = CX({ default: 1, z2: 1, px15: 1, videoPlayerControlsBackground: 1, posAbs: 1, bottom0: 1, right0: 1, left0: 1, displayNone: !hovered && !paused, }) const overlayClasses = CX({ default: 1, top50PC: 1, left50PC: 1, posAbs: 1, z2: 1, alignItemsCenter: 1, justifyContentCenter: 1, displayNone: !paused, }) if (!revealed && sensitive) { return } return (
{ paused && !isBuffering && } { !paused && isBuffering && }