diff --git a/Gemfile b/Gemfile index dbdab7cc206..33f9374cf15 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.1', require: false gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' gem 'posix-spawn' +gem 'streamio-ffmpeg', '~> 3.0' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' diff --git a/Gemfile.lock b/Gemfile.lock index 99acf91bf78..d4ebd0a4047 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -550,6 +550,8 @@ GEM net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-ruby (1.2.1) + streamio-ffmpeg (3.0.2) + multi_json (~> 1.8) strong_migrations (0.1.9) activerecord (>= 3.2.0) temple (0.8.0) @@ -712,6 +714,7 @@ DEPENDENCIES simple_form (~> 3.4) simplecov (~> 0.14) sprockets-rails (~> 3.2) + streamio-ffmpeg (~> 3.0) strong_migrations tty-command tty-prompt diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index f652f5acef9..88c7232dd84 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -3,20 +3,26 @@ class MediaController < ApplicationController include Authorization - before_action :verify_permitted_status + before_action :set_media_attachment + before_action :verify_permitted_status! def show - redirect_to media_attachment.file.url(:original) + redirect_to @media_attachment.file.url(:original) + end + + def player + @body_classes = 'player' + raise ActiveRecord::RecordNotFound unless @media_attachment.video? || @media_attachment.gifv? end private - def media_attachment - MediaAttachment.attached.find_by!(shortcode: params[:id]) + def set_media_attachment + @media_attachment = MediaAttachment.attached.find_by!(shortcode: params[:id] || params[:medium_id]) end - def verify_permitted_status - authorize media_attachment.status, :show? + def verify_permitted_status! + authorize @media_attachment.status, :show? rescue Mastodon::NotPermittedError # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a276b17d512..00943e205b3 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -249,7 +249,7 @@ export default class MediaGallery extends React.PureComponent { } children = ( - diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 2f6a7831e99..b52f3c4fafd 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -20,6 +20,39 @@ const getHostname = url => { return parser.hostname; }; +const trim = (text, len) => { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +}; + +const domParser = new DOMParser(); + +const addAutoPlay = html => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + if (iframe.src.indexOf('?') !== -1) { + iframe.src += '&'; + } else { + iframe.src += '?'; + } + + iframe.src += 'autoplay=1&auto_play=1'; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body').innerHTML; + } + + return html; +}; + export default class Card extends React.PureComponent { static propTypes = { @@ -33,9 +66,16 @@ export default class Card extends React.PureComponent { }; state = { - width: 0, + width: 280, + embedded: false, }; + componentWillReceiveProps (nextProps) { + if (this.props.card !== nextProps.card) { + this.setState({ embedded: false }); + } + } + handlePhotoClick = () => { const { card, onOpenMedia } = this.props; @@ -57,56 +97,14 @@ export default class Card extends React.PureComponent { ); }; - renderLink () { - const { card, maxDescription } = this.props; - const { width } = this.state; - const horizontal = card.get('width') > card.get('height') && (card.get('width') + 100 >= width); - - let image = ''; - let provider = card.get('provider_name'); - - if (card.get('image')) { - image = ( -
- {card.get('title')} -
- ); - } - - if (provider.length < 1) { - provider = decodeIDNA(getHostname(card.get('url'))); - } - - const className = classnames('status-card', { horizontal }); - - return ( - - {image} - -
- {card.get('title')} - {!horizontal &&

{(card.get('description') || '').substring(0, maxDescription)}

} - {provider} -
-
- ); - } - - renderPhoto () { + handleEmbedClick = () => { const { card } = this.props; - return ( - {card.get('title')} - ); + if (card.get('type') === 'photo') { + this.handlePhotoClick(); + } else { + this.setState({ embedded: true }); + } } setRef = c => { @@ -117,7 +115,7 @@ export default class Card extends React.PureComponent { renderVideo () { const { card } = this.props; - const content = { __html: card.get('html') }; + const content = { __html: addAutoPlay(card.get('html')) }; const { width } = this.state; const ratio = card.get('width') / card.get('height'); const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); @@ -125,7 +123,7 @@ export default class Card extends React.PureComponent { return (
@@ -133,23 +131,76 @@ export default class Card extends React.PureComponent { } render () { - const { card } = this.props; + const { card, maxDescription } = this.props; + const { width, embedded } = this.state; if (card === null) { return null; } - switch(card.get('type')) { - case 'link': - return this.renderLink(); - case 'photo': - return this.renderPhoto(); - case 'video': - return this.renderVideo(); - case 'rich': - default: - return null; + const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); + const horizontal = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link'; + const className = classnames('status-card', { horizontal }); + const interactive = card.get('type') !== 'link'; + const title = interactive ? {card.get('title')} : {card.get('title')}; + const ratio = card.get('width') / card.get('height'); + const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); + + const description = ( +
+ {title} + {!horizontal &&

{trim(card.get('description') || '', maxDescription)}

} + {provider} +
+ ); + + let embed = ''; + let thumbnail =
; + + if (interactive) { + if (embedded) { + embed = this.renderVideo(); + } else { + let iconVariant = 'play'; + + if (card.get('type') === 'photo') { + iconVariant = 'search-plus'; + } + + embed = ( +
+ {thumbnail} + +
+
+ + +
+
+
+ ); + } + + return ( +
+ {embed} + {description} +
+ ); + } else if (card.get('image')) { + embed = ( +
+ {thumbnail} +
+ ); } + + return ( + + {embed} + {description} + + ); } } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 7752fc05784..6335d84b6eb 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -271,7 +271,7 @@ export default class Video extends React.PureComponent { onProgress={this.handleProgress} /> - @@ -290,10 +290,10 @@ export default class Video extends React.PureComponent {
- - + + - {!onCloseVideo && } + {!onCloseVideo && } {(detailed || fullscreen) && @@ -305,9 +305,9 @@ export default class Video extends React.PureComponent {
- {(!fullscreen && onOpenVideo) && } - {onCloseVideo && } - + {(!fullscreen && onOpenVideo) && } + {onCloseVideo && } +
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index b45ab5ceecf..bcc28b65a92 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -8,7 +8,7 @@ "account.follows": "Śledzeni", "account.follows_you": "Śledzi Cię", "account.hide_reblogs": "Ukryj podbicia od @{name}", - "account.media": "Media", + "account.media": "Zawartość multimedialna", "account.mention": "Wspomnij o @{name}", "account.moved_to": "{name} przeniósł się do:", "account.mute": "Wycisz @{name}", @@ -134,7 +134,7 @@ "lightbox.next": "Następne", "lightbox.previous": "Poprzednie", "lists.account.add": "Dodaj do listy", - "lists.account.remove": "Remove from list", + "lists.account.remove": "Usuń z listy", "lists.delete": "Usuń listę", "lists.edit": "Edytuj listę", "lists.new.create": "Utwórz listę", @@ -144,7 +144,7 @@ "loading_indicator.label": "Ładowanie…", "media_gallery.toggle_visible": "Przełącz widoczność", "missing_indicator.label": "Nie znaleziono", - "missing_indicator.sublabel": "This resource could not be found", + "missing_indicator.sublabel": "Nie można odnaleźć tego zasobu", "mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?", "navigation_bar.blocks": "Zablokowani użytkownicy", "navigation_bar.community_timeline": "Lokalna oś czasu", @@ -206,8 +206,8 @@ "privacy.public.short": "Publiczny", "privacy.unlisted.long": "Niewidoczny na publicznych osiach czasu", "privacy.unlisted.short": "Niewidoczny", - "regeneration_indicator.label": "Loading…", - "regeneration_indicator.sublabel": "Your home feed is being prepared!", + "regeneration_indicator.label": "Ładowanie…", + "regeneration_indicator.sublabel": "Twoja oś czasu jest przygotowywana!", "relative_time.days": "{number} dni", "relative_time.hours": "{number} godz.", "relative_time.just_now": "teraz", @@ -223,6 +223,9 @@ "search_popout.tips.status": "wpis", "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów", "search_popout.tips.user": "użytkownik", + "search_results.accounts": "Ludzie", + "search_results.hashtags": "Hashtagi", + "search_results.statuses": "Wpisy", "search_results.total": "{count, number} {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}", "standalone.public_title": "Spojrzenie w głąb…", "status.block": "Zablokuj @{name}", diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index b5d77ff63bc..0e3b9ad6bfe 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -47,6 +47,10 @@ body { padding-bottom: 0; } + &.player { + text-align: center; + } + &.embed { background: transparent; margin: 0; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index b6244e3f964..d57481f9750 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2208,7 +2208,6 @@ .status-card { display: flex; - cursor: pointer; font-size: 14px; border: 1px solid lighten($ui-base-color, 8%); border-radius: 4px; @@ -2217,20 +2216,58 @@ text-decoration: none; overflow: hidden; - &:hover { - background: lighten($ui-base-color, 8%); + &__actions { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; + + & > div { + background: rgba($base-shadow-color, 0.6); + border-radius: 4px; + padding: 12px 9px; + flex: 0 0 auto; + display: flex; + justify-content: center; + align-items: center; + } + + button, + a { + display: inline; + color: $primary-text-color; + background: transparent; + border: 0; + padding: 0 5px; + text-decoration: none; + opacity: 0.6; + font-size: 18px; + line-height: 18px; + + &:hover, + &:active, + &:focus { + opacity: 1; + } + } + + a { + font-size: 19px; + position: relative; + bottom: -1px; + } } } -.status-card-video, -.status-card-rich, -.status-card-photo { - margin-top: 14px; - overflow: hidden; +a.status-card { + cursor: pointer; - iframe { - width: 100%; - height: auto; + &:hover { + background: lighten($ui-base-color, 8%); } } @@ -2258,6 +2295,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-decoration: none; } .status-card__content { @@ -2279,6 +2317,7 @@ .status-card__image { flex: 0 0 100px; background: lighten($ui-base-color, 8%); + position: relative; } .status-card.horizontal { @@ -2304,6 +2343,8 @@ width: 100%; height: 100%; object-fit: cover; + background-size: cover; + background-position: center center; } .load-more { diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss index a35bbee7957..e11ca29d9e9 100644 --- a/app/javascript/styles/mastodon/stream_entries.scss +++ b/app/javascript/styles/mastodon/stream_entries.scss @@ -65,6 +65,10 @@ } } + .media-gallery__gifv__label { + bottom: 9px; + } + .status.light { padding: 14px 14px 14px (48px + 14px * 2); position: relative; @@ -258,12 +262,12 @@ .media-spoiler { background: $ui-primary-color; color: $white; - transition: all 100ms linear; + transition: all 40ms linear; &:hover, &:active, &:focus { - background: darken($ui-primary-color, 5%); + background: darken($ui-primary-color, 2%); color: unset; } } diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 25b7fd0857c..6f17363c847 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -181,23 +181,39 @@ class MediaAttachment < ApplicationRecord meta = {} file.queued_for_write.each do |style, file| - begin - geo = Paperclip::Geometry.from_file file - - meta[style] = { - width: geo.width.to_i, - height: geo.height.to_i, - size: "#{geo.width.to_i}x#{geo.height.to_i}", - aspect: geo.width.to_f / geo.height.to_f, - } - rescue Paperclip::Errors::NotIdentifiedByImageMagickError - meta[style] = {} - end + meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file) end meta end + def image_geometry(file) + geo = Paperclip::Geometry.from_file file + + { + width: geo.width.to_i, + height: geo.height.to_i, + size: "#{geo.width.to_i}x#{geo.height.to_i}", + aspect: geo.width.to_f / geo.height.to_f, + } + rescue Paperclip::Errors::NotIdentifiedByImageMagickError + {} + end + + def video_metadata(file) + movie = FFMPEG::Movie.new(file.path) + + return {} unless movie.valid? + + { + width: movie.width, + height: movie.height, + frame_rate: movie.frame_rate, + duration: movie.duration, + bitrate: movie.bitrate, + } + end + def appropriate_extension mime_type = MIME::Types[file.content_type] diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 3e31a414570..8f252e64c68 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -94,14 +94,16 @@ class FetchLinkCardService < BaseService @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url) when 'photo' return false unless embed.respond_to?(:url) + @card.embed_url = embed.url @card.image_remote_url = embed.url @card.width = embed.width.presence || 0 @card.height = embed.height.presence || 0 when 'video' - @card.width = embed.width.presence || 0 - @card.height = embed.height.presence || 0 - @card.html = Formatter.instance.sanitize(embed.html, Sanitize::Config::MASTODON_OEMBED) + @card.width = embed.width.presence || 0 + @card.height = embed.height.presence || 0 + @card.html = Formatter.instance.sanitize(embed.html, Sanitize::Config::MASTODON_OEMBED) + @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url) when 'rich' # Most providers rely on