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 (
+
+
+
+
+
+
+
+
+
+
+
+ {formatTime(currentTime)}
+ /
+ {formatTime(duration)}
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b0dea8817..ef69104e3 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -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 = ;
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const audio = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ );
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 2a15c052f..2df510407 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -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');
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c880e99a9..7eead20a3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 62a11185a..5d34d31c9 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -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
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 57f1e0098..8df5329a9 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -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
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 300eae547..b3da4fcd5 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -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
diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml
index ea868b3f6..f03595183 100644
--- a/app/views/media/player.html.haml
+++ b/app/views/media/player.html.haml
@@ -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" }
- %source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }
+- 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 }
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 6e6d0eda8..6464951c5 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -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
diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml
index e1b977da3..b851302aa 100644
--- a/app/views/stream_entries/_og_image.html.haml
+++ b/app/views/stream_entries/_og_image.html.haml
@@ -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
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 1a1dc37eb..84307ceea 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -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