Add audio uploads

Fix #4827
This commit is contained in:
Eugen Rochko 2018-12-09 23:05:30 +01:00
parent ae3d2f446a
commit 6542c68a59
14 changed files with 436 additions and 29 deletions

View File

@ -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

View File

@ -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 <div className='media-spoiler-video' style={{ height: '110px' }} />;
}
renderLoadingAudioPlayer () {
return <div className='media-spoiler-video' style={{ height: '61px' }} />;
}
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 = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => <Component src={audio.get('url')} duration={audio.getIn(['meta', 'original', 'duration'])} />}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);

View File

@ -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 {

View File

@ -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 (
<div
role='menuitem'
className='video-player inline audio-player'
onClick={this.handleClickRoot}
tabIndex={0}
>
<audio
ref={this.setAudioRef}
src={src}
preload='none'
loop
role='button'
tabIndex='0'
volume={volume}
onClick={this.togglePlay}
onPlay={this.handlePlay}
onPause={this.handlePause}
onTimeUpdate={this.handleTimeUpdate}
onProgress={this.handleProgress}
/>
<div className='video-player__controls active'>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex='0'
style={{ left: `${progress}%` }}
/>
</div>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onMouseEnter={this.volumeSlider} onMouseLeave={this.volumeSlider} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<span
className={classNames('video-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
/>
</div>
</div>
<div className='video-player__buttons right'>
<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(duration)}</span>
</span>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
import Audio from '../../audio';
export default class DetailedStatus extends ImmutablePureComponent {
@ -53,6 +54,12 @@ export default class DetailedStatus extends ImmutablePureComponent {
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />;
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const audio = status.getIn(['media_attachments', 0]);
media = (
<Audio src={audio.get('url')} duration={audio.getIn(['meta', 'original', 'duration'])} />
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);

View File

@ -122,6 +122,10 @@ export function Video () {
return import(/* webpackChunkName: "features/video" */'../../video');
}
export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
}
export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
}

View File

@ -505,6 +505,7 @@
.compose-form__upload-thumbnail {
border-radius: 4px;
background-color: $base-shadow-color;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
@ -4947,6 +4948,36 @@ a.status-card.compact:hover {
}
}
.audio-player {
.video-player__controls {
position: static;
background: transparent;
padding-top: 6px;
}
.video-player__buttons button {
color: $darker-text-color;
&:hover,
&:focus,
&:active {
color: lighten($darker-text-color, 4%);
}
}
.video-player__time-sep,
.video-player__time-total,
.video-player__time-current {
color: $dark-text-color;
line-height: 23px;
}
.video-player__seek::before,
.video-player__volume::before {
background: lighten($ui-base-color, 8%);
}
}
.media-spoiler-video {
background-size: cover;
background-repeat: no-repeat;

View File

@ -22,14 +22,16 @@
class MediaAttachment < ApplicationRecord
self.inheritance_column = nil
enum type: [:image, :gifv, :video, :unknown]
enum type: %i(image gifv video unknown audio)
IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3).freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime).freeze
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
AUDIO_MIME_TYPES = %w(audio/ogg video/ogg audio/mpeg).freeze
IMAGE_STYLES = {
original: {
@ -73,6 +75,23 @@ class MediaAttachment < ApplicationRecord
},
}.freeze
VIDEO_CONVERTED_STYLES = {
small: VIDEO_STYLES[:small],
original: VIDEO_FORMAT,
}.freeze
AUDIO_STYLES = {
original: {
format: 'ogg',
convert_options: {},
},
}.freeze
GIFV_STYLES = {
small: IMAGE_STYLES[:small],
original: VIDEO_FORMAT,
}.freeze
IMAGE_LIMIT = 8.megabytes
VIDEO_LIMIT = 40.megabytes
@ -84,7 +103,7 @@ class MediaAttachment < ApplicationRecord
processors: ->(f) { file_processors f },
convert_options: { all: '-quality 90 -strip' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :video?
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video?
remotable_attachment :file, VIDEO_LIMIT
@ -142,26 +161,24 @@ class MediaAttachment < ApplicationRecord
def file_styles(f)
if f.instance.file_content_type == 'image/gif'
{
small: IMAGE_STYLES[:small],
original: VIDEO_FORMAT,
}
elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
GIFV_STYLES
elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type)
IMAGE_STYLES
elsif VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
{
small: VIDEO_STYLES[:small],
original: VIDEO_FORMAT,
}
else
VIDEO_CONVERTED_STYLES
elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type)
VIDEO_STYLES
else
AUDIO_STYLES
end
end
def file_processors(f)
if f.file_content_type == 'image/gif'
[:gif_transcoder]
elsif VIDEO_MIME_TYPES.include? f.file_content_type
elsif AUDIO_MIME_TYPES.include?(f.file_content_type)
[:transcoder]
elsif VIDEO_MIME_TYPES.include?(f.file_content_type)
[:video_transcoder]
else
[:lazy_thumbnail]
@ -187,7 +204,13 @@ class MediaAttachment < ApplicationRecord
end
def set_type_and_extension
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
self.type = if VIDEO_MIME_TYPES.include?(file_content_type)
:video
elsif AUDIO_MIME_TYPES.include?(file_content_type)
:audio
else
:image
end
end
def set_meta
@ -230,7 +253,7 @@ class MediaAttachment < ApplicationRecord
frame_rate: movie.frame_rate,
duration: movie.duration,
bitrate: movie.bitrate,
}
}.compact
end
def reset_parent_cache

View File

@ -55,7 +55,7 @@ class InitialStateSerializer < ActiveModel::Serializer
end
def media_attachments
{ accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES }
{ accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::AUDIO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES }
end
private

View File

@ -61,7 +61,7 @@ class PostStatusService < BaseService
media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media.size > 1 && media.find(&:video?)
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media.size > 1 && media.find { |x| x.audio? || x.video? }
media
end

View File

@ -1,2 +1,6 @@
- if @media_attachment.audio?
%audio{ preload: 'auto', controls: 'controls' }
%source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }
- else
%video{ poster: @media_attachment.file.url(:small), preload: 'auto', autoplay: 'autoplay', muted: 'muted', loop: 'loop', controls: 'controls', style: "width: #{@media_attachment.file.meta.dig('original', 'width')}px; height: #{@media_attachment.file.meta.dig('original', 'height')}px" }
%source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }

View File

@ -23,7 +23,10 @@
.e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- if !status.media_attachments.empty?
- if status.media_attachments.first.video?
- if status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), duration: audio.file.meta.dig(:original, :duration)
- elsif status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description
- else

View File

@ -25,6 +25,11 @@
= opengraph 'og:video:height', media.file.meta.dig('original', 'height')
= opengraph 'twitter:player:width', media.file.meta.dig('original', 'width')
= opengraph 'twitter:player:height', media.file.meta.dig('original', 'height')
- elsif media.audio?
- player_card = true
= opengraph 'og:audio', full_asset_url(media.file.url(:original))
= opengraph 'og:audio:secure_url', full_asset_url(media.file.url(:original))
= opengraph 'og:audio:type', media.file_content_type
- if player_card
= opengraph 'twitter:card', 'player'
- else

View File

@ -27,7 +27,10 @@
.e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- unless status.media_attachments.empty?
- if status.media_attachments.first.video?
- if status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), duration: audio.file.meta.dig(:original, :duration)
- elsif status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description
- else