From 6542c68a59a5de1cadd0f8bd8000877115fe5cdb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 9 Dec 2018 23:05:30 +0100 Subject: [PATCH] Add audio uploads Fix #4827 --- app/controllers/media_controller.rb | 11 +- app/javascript/mastodon/components/status.js | 14 +- .../mastodon/containers/media_container.js | 3 +- .../mastodon/features/audio/index.js | 305 ++++++++++++++++++ .../status/components/detailed_status.js | 7 + .../features/ui/util/async-components.js | 4 + .../styles/mastodon/components.scss | 31 ++ app/models/media_attachment.rb | 63 ++-- app/serializers/initial_state_serializer.rb | 2 +- app/services/post_status_service.rb | 2 +- app/views/media/player.html.haml | 8 +- .../stream_entries/_detailed_status.html.haml | 5 +- app/views/stream_entries/_og_image.html.haml | 5 + .../stream_entries/_simple_status.html.haml | 5 +- 14 files changed, 436 insertions(+), 29 deletions(-) create mode 100644 app/javascript/mastodon/features/audio/index.js diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 88c7232dd..a775489d0 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -5,6 +5,7 @@ class MediaController < ApplicationController before_action :set_media_attachment before_action :verify_permitted_status! + before_action :check_playable!, only: :player def show redirect_to @media_attachment.file.url(:original) @@ -12,7 +13,7 @@ class MediaController < ApplicationController def player @body_classes = 'player' - raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv? + @autoplay = truthy_param?(:autoplay) || truthy_param?(:auto_play) end private @@ -27,4 +28,12 @@ class MediaController < ApplicationController # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound end + + def check_playable! + raise ActiveRecord::RecordNotFound unless playable? + end + + def playable? + @media_attachment.video? || @media_attachment.gifv? || @media_attachment.audio? + end end diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index fd0780025..32af95b59 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,7 +12,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; @@ -113,6 +113,10 @@ class Status extends ImmutablePureComponent { return
; } + renderLoadingAudioPlayer () { + return
; + } + handleOpenVideo = (media, startTime) => { this.props.onOpenVideo(media, startTime); } @@ -232,6 +236,14 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const audio = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => } + + ); } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const video = status.getIn(['media_attachments', 0]); diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 43bb39403..badfd8821 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -5,6 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import MediaGallery from '../components/media_gallery'; import Video from '../features/video'; +import Audio from '../features/audio'; import Card from '../features/status/components/card'; import ModalRoot from '../components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; @@ -13,7 +14,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Audio, Card }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js new file mode 100644 index 000000000..3f3f1d488 --- /dev/null +++ b/app/javascript/mastodon/features/audio/index.js @@ -0,0 +1,305 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { throttle } from 'lodash'; +import classNames from 'classnames'; + +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' }, +}); + +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; +}; + +// hard coded in components.scss +// any way to get ::before values programatically? +const VOL_WIDTH = 50; +const VOL_OFFSET = 70; + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + duration: PropTypes.number, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: 0, + volume: 0.5, + paused: true, + dragging: false, + fullscreen: false, + muted: false, + }; + + volHandleOffset = v => { + const offset = v * VOL_WIDTH + VOL_OFFSET; + return (offset > 110) ? 110 : offset; + } + + componentDidMount () { + this.setState({ duration: this.props.duration }); + } + + componentWillReceiveProps (nextProps) { + this.setState({ duration: nextProps.duration }); + } + + setAudioRef = c => { + this.audio = c; + } + + setSeekRef = c => { + this.seek = c; + } + + setVolumeRef = c => { + this.volume = c; + } + + handleClickRoot = e => e.stopPropagation(); + + handlePlay = () => { + this.setState({ paused: false }); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + handleTimeUpdate = () => { + this.setState({ + currentTime: Math.floor(this.audio.currentTime), + duration: Math.floor(this.audio.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) / VOL_WIDTH; //x position within the element. + + if(!isNaN(x)) { + var slideamt = x; + + if(x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.audio.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.audio.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.audio.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + const currentTime = Math.floor(this.audio.duration * x); + + if (!isNaN(currentTime)) { + this.audio.currentTime = currentTime; + this.setState({ currentTime }); + } + }, 60); + + togglePlay = () => { + if (this.state.paused) { + this.audio.play(); + } else { + this.audio.pause(); + } + } + + toggleMute = () => { + this.audio.muted = !this.audio.muted; + this.setState({ muted: this.audio.muted }); + } + + handleProgress = () => { + if (this.audio.buffered.length > 0) { + this.setState({ buffer: this.audio.buffered.end(0) / this.audio.duration * 100 }); + } + } + + render () { + const { src, intl } = this.props; + const { currentTime, duration, volume, buffer, dragging, paused, muted } = this.state; + const progress = (currentTime / duration) * 100; + const volumeWidth = (muted) ? 0 : volume * VOL_WIDTH; + const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
+