mirror of
https://framagit.org/tykayn/mastodon.git
synced 2023-08-25 08:33:12 +02:00
parent
ae3d2f446a
commit
6542c68a59
@ -5,6 +5,7 @@ class MediaController < ApplicationController
|
|||||||
|
|
||||||
before_action :set_media_attachment
|
before_action :set_media_attachment
|
||||||
before_action :verify_permitted_status!
|
before_action :verify_permitted_status!
|
||||||
|
before_action :check_playable!, only: :player
|
||||||
|
|
||||||
def show
|
def show
|
||||||
redirect_to @media_attachment.file.url(:original)
|
redirect_to @media_attachment.file.url(:original)
|
||||||
@ -12,7 +13,7 @@ class MediaController < ApplicationController
|
|||||||
|
|
||||||
def player
|
def player
|
||||||
@body_classes = 'player'
|
@body_classes = 'player'
|
||||||
raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv?
|
@autoplay = truthy_param?(:autoplay) || truthy_param?(:auto_play)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@ -27,4 +28,12 @@ class MediaController < ApplicationController
|
|||||||
# Reraise in order to get a 404 instead of a 403 error code
|
# Reraise in order to get a 404 instead of a 403 error code
|
||||||
raise ActiveRecord::RecordNotFound
|
raise ActiveRecord::RecordNotFound
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_playable!
|
||||||
|
raise ActiveRecord::RecordNotFound unless playable?
|
||||||
|
end
|
||||||
|
|
||||||
|
def playable?
|
||||||
|
@media_attachment.video? || @media_attachment.gifv? || @media_attachment.audio?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
|
|||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
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 { HotKeys } from 'react-hotkeys';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
@ -113,6 +113,10 @@ class Status extends ImmutablePureComponent {
|
|||||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderLoadingAudioPlayer () {
|
||||||
|
return <div className='media-spoiler-video' style={{ height: '61px' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
handleOpenVideo = (media, startTime) => {
|
handleOpenVideo = (media, startTime) => {
|
||||||
this.props.onOpenVideo(media, startTime);
|
this.props.onOpenVideo(media, startTime);
|
||||||
}
|
}
|
||||||
@ -232,6 +236,14 @@ class Status extends ImmutablePureComponent {
|
|||||||
media={status.get('media_attachments')}
|
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') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
const video = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
|
|||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import MediaGallery from '../components/media_gallery';
|
import MediaGallery from '../components/media_gallery';
|
||||||
import Video from '../features/video';
|
import Video from '../features/video';
|
||||||
|
import Audio from '../features/audio';
|
||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
import MediaModal from '../features/ui/components/media_modal';
|
import MediaModal from '../features/ui/components/media_modal';
|
||||||
@ -13,7 +14,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
|
|||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
|
||||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
|
const MEDIA_COMPONENTS = { MediaGallery, Video, Audio, Card };
|
||||||
|
|
||||||
export default class MediaContainer extends PureComponent {
|
export default class MediaContainer extends PureComponent {
|
||||||
|
|
||||||
|
305
app/javascript/mastodon/features/audio/index.js
Normal file
305
app/javascript/mastodon/features/audio/index.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
|
|||||||
import Card from './card';
|
import Card from './card';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Video from '../../video';
|
import Video from '../../video';
|
||||||
|
import Audio from '../../audio';
|
||||||
|
|
||||||
export default class DetailedStatus extends ImmutablePureComponent {
|
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').size > 0) {
|
||||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||||
media = <AttachmentList media={status.get('media_attachments')} />;
|
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') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
const video = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
@ -122,6 +122,10 @@ export function Video () {
|
|||||||
return import(/* webpackChunkName: "features/video" */'../../video');
|
return import(/* webpackChunkName: "features/video" */'../../video');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Audio () {
|
||||||
|
return import(/* webpackChunkName: "features/audio" */'../../audio');
|
||||||
|
}
|
||||||
|
|
||||||
export function EmbedModal () {
|
export function EmbedModal () {
|
||||||
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
|
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
|
||||||
}
|
}
|
||||||
|
@ -505,6 +505,7 @@
|
|||||||
|
|
||||||
.compose-form__upload-thumbnail {
|
.compose-form__upload-thumbnail {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
background-color: $base-shadow-color;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
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 {
|
.media-spoiler-video {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
@ -22,14 +22,16 @@
|
|||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
self.inheritance_column = nil
|
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
|
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
|
||||||
VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].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
|
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
|
||||||
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
|
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime).freeze
|
||||||
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', '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 = {
|
IMAGE_STYLES = {
|
||||||
original: {
|
original: {
|
||||||
@ -73,6 +75,23 @@ class MediaAttachment < ApplicationRecord
|
|||||||
},
|
},
|
||||||
}.freeze
|
}.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
|
IMAGE_LIMIT = 8.megabytes
|
||||||
VIDEO_LIMIT = 40.megabytes
|
VIDEO_LIMIT = 40.megabytes
|
||||||
|
|
||||||
@ -84,7 +103,7 @@ class MediaAttachment < ApplicationRecord
|
|||||||
processors: ->(f) { file_processors f },
|
processors: ->(f) { file_processors f },
|
||||||
convert_options: { all: '-quality 90 -strip' }
|
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: IMAGE_LIMIT, unless: :video?
|
||||||
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video?
|
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video?
|
||||||
remotable_attachment :file, VIDEO_LIMIT
|
remotable_attachment :file, VIDEO_LIMIT
|
||||||
@ -142,26 +161,24 @@ class MediaAttachment < ApplicationRecord
|
|||||||
|
|
||||||
def file_styles(f)
|
def file_styles(f)
|
||||||
if f.instance.file_content_type == 'image/gif'
|
if f.instance.file_content_type == 'image/gif'
|
||||||
{
|
GIFV_STYLES
|
||||||
small: IMAGE_STYLES[:small],
|
elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||||
original: VIDEO_FORMAT,
|
|
||||||
}
|
|
||||||
elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
|
|
||||||
IMAGE_STYLES
|
IMAGE_STYLES
|
||||||
elsif VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
|
elsif VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||||
{
|
VIDEO_CONVERTED_STYLES
|
||||||
small: VIDEO_STYLES[:small],
|
elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type)
|
||||||
original: VIDEO_FORMAT,
|
|
||||||
}
|
|
||||||
else
|
|
||||||
VIDEO_STYLES
|
VIDEO_STYLES
|
||||||
|
else
|
||||||
|
AUDIO_STYLES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def file_processors(f)
|
def file_processors(f)
|
||||||
if f.file_content_type == 'image/gif'
|
if f.file_content_type == 'image/gif'
|
||||||
[:gif_transcoder]
|
[: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]
|
[:video_transcoder]
|
||||||
else
|
else
|
||||||
[:lazy_thumbnail]
|
[:lazy_thumbnail]
|
||||||
@ -187,7 +204,13 @@ class MediaAttachment < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_type_and_extension
|
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
|
end
|
||||||
|
|
||||||
def set_meta
|
def set_meta
|
||||||
@ -230,7 +253,7 @@ class MediaAttachment < ApplicationRecord
|
|||||||
frame_rate: movie.frame_rate,
|
frame_rate: movie.frame_rate,
|
||||||
duration: movie.duration,
|
duration: movie.duration,
|
||||||
bitrate: movie.bitrate,
|
bitrate: movie.bitrate,
|
||||||
}
|
}.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_parent_cache
|
def reset_parent_cache
|
||||||
|
@ -55,7 +55,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def media_attachments
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -61,7 +61,7 @@ class PostStatusService < BaseService
|
|||||||
|
|
||||||
media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
|
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
|
media
|
||||||
end
|
end
|
||||||
|
@ -1,2 +1,6 @@
|
|||||||
%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" }
|
- 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 }
|
%source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }
|
||||||
|
@ -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)
|
.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.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
|
- 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
|
= 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
|
- else
|
||||||
|
@ -25,6 +25,11 @@
|
|||||||
= opengraph 'og:video:height', media.file.meta.dig('original', 'height')
|
= opengraph 'og:video:height', media.file.meta.dig('original', 'height')
|
||||||
= opengraph 'twitter:player:width', media.file.meta.dig('original', 'width')
|
= opengraph 'twitter:player:width', media.file.meta.dig('original', 'width')
|
||||||
= opengraph 'twitter:player:height', media.file.meta.dig('original', 'height')
|
= 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
|
- if player_card
|
||||||
= opengraph 'twitter:card', 'player'
|
= opengraph 'twitter:card', 'player'
|
||||||
- else
|
- else
|
||||||
|
@ -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)
|
.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?
|
- 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
|
- 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
|
= 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
|
- else
|
||||||
|
Loading…
Reference in New Issue
Block a user