diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js
new file mode 100644
index 000000000..d49e08e65
--- /dev/null
+++ b/app/soapbox/features/audio/index.js
@@ -0,0 +1,521 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { fromJS, is } from 'immutable';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
+import Icon from 'soapbox/components/icon';
+import { decode } from 'blurhash';
+import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from '../../utils/media_aspect_ratio';
+import { getSettings } from 'soapbox/actions/settings';
+
+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' },
+ expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+ close: { id: 'video.close', defaultMessage: 'Close video' },
+ fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+ exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const formatTime = secondsNum => {
+ let hours = Math.floor(secondsNum / 3600);
+ let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
+ let seconds = 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 mapStateToProps = state => ({
+ displayMedia: getSettings(state).get('displayMedia'),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Video extends React.PureComponent {
+
+ static propTypes = {
+ preview: PropTypes.string,
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ startTime: PropTypes.number,
+ onOpenVideo: PropTypes.func,
+ onCloseVideo: PropTypes.func,
+ detailed: PropTypes.bool,
+ inline: PropTypes.bool,
+ cacheWidth: PropTypes.func,
+ visible: PropTypes.bool,
+ onToggleVisibility: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ blurhash: PropTypes.string,
+ link: PropTypes.node,
+ aspectRatio: PropTypes.number,
+ displayMedia: PropTypes.string,
+ };
+
+ state = {
+ currentTime: 0,
+ duration: 0,
+ volume: 0.5,
+ paused: true,
+ dragging: false,
+ containerWidth: this.props.width,
+ fullscreen: false,
+ hovered: false,
+ muted: false,
+ revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'),
+ };
+
+ // hard coded in components.scss
+ // any way to get ::before values programatically?
+ volWidth = 50;
+ volOffset = 70;
+ volHandleOffset = v => {
+ const offset = v * this.volWidth + this.volOffset;
+ return (offset > 110) ? 110 : offset;
+ }
+
+ setPlayerRef = c => {
+ this.player = c;
+
+ if (c) {
+ if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
+ this.setState({
+ containerWidth: c.offsetWidth,
+ });
+ }
+ }
+
+ setVideoRef = c => {
+ this.video = c;
+
+ if (this.video) {
+ this.setState({ volume: this.video.volume, muted: this.video.muted });
+ }
+ }
+
+ setSeekRef = c => {
+ this.seek = c;
+ }
+
+ setVolumeRef = c => {
+ this.volume = c;
+ }
+
+ setCanvasRef = c => {
+ this.canvas = c;
+ }
+
+ handleClickRoot = e => e.stopPropagation();
+
+ handlePlay = () => {
+ this.setState({ paused: false });
+ }
+
+ handlePause = () => {
+ this.setState({ paused: true });
+ }
+
+ handleTimeUpdate = () => {
+ this.setState({
+ currentTime: Math.floor(this.video.currentTime),
+ duration: Math.floor(this.video.duration),
+ });
+ }
+
+ 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();
+ }
+
+ handleVolumeMouseUp = () => {
+ document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+ document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+ document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+ document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+ }
+
+ handleMouseVolSlide = throttle(e => {
+ const rect = this.volume.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
+
+ if(!isNaN(x)) {
+ var slideamt = x;
+ if(x > 1) {
+ slideamt = 1;
+ } else if(x < 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 = Math.floor(this.video.duration * x);
+
+ 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);
+ }
+ }
+
+ componentDidMount() {
+ 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 (this.props.blurhash) {
+ this._decode();
+ }
+ }
+
+ 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);
+ }
+
+ 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();
+ }
+ if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
+ this._decode();
+ }
+ }
+
+ _decode() {
+ const hash = this.props.blurhash;
+ const pixels = decode(hash, 32, 32);
+
+ if (pixels) {
+ const ctx = this.canvas.getContext('2d');
+ const imageData = new ImageData(pixels, 32, 32);
+
+ ctx.putImageData(imageData, 0, 0);
+ }
+ }
+
+ handleFullscreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ handleMouseEnter = () => {
+ this.setState({ hovered: true });
+ }
+
+ handleMouseLeave = () => {
+ this.setState({ hovered: false });
+ }
+
+ 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 = () => {
+ if (this.video.buffered.length > 0) {
+ this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+ }
+ }
+
+ handleVolumeChange = () => {
+ this.setState({ volume: this.video.volume, muted: this.video.muted });
+ }
+
+ handleOpenVideo = () => {
+ const { src, preview, width, height, alt } = this.props;
+
+ const media = fromJS({
+ type: 'video',
+ url: src,
+ preview_url: preview,
+ description: alt,
+ width,
+ height,
+ });
+
+ this.video.pause();
+ this.props.onOpenVideo(media, this.video.currentTime);
+ }
+
+ handleCloseVideo = () => {
+ this.video.pause();
+ this.props.onCloseVideo();
+ }
+
+ getPreload = () => {
+ const { startTime, detailed } = this.props;
+ const { dragging, fullscreen } = this.state;
+
+ if (startTime || fullscreen || dragging) {
+ return 'auto';
+ } else if (detailed) {
+ return 'metadata';
+ } else {
+ return 'none';
+ }
+ }
+
+ render() {
+ const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio } = this.props;
+ const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+ const progress = (currentTime / duration) * 100;
+
+ const volumeWidth = (muted) ? 0 : volume * this.volWidth;
+ 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);
+ }
+
+ if (height) playerStyle.height = height;
+ }
+
+ let warning;
+
+ if (sensitive) {
+ warning =