diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
index eb792ae3b..f4bfb5929 100644
--- a/internal/gtsmodel/mediaattachment.go
+++ b/internal/gtsmodel/mediaattachment.go
@@ -91,6 +91,7 @@ type Thumbnail struct {
FileTypeImage FileType = 1 // FileTypeImage is for jpegs, pngs, and standard gifs
FileTypeAudio FileType = 2 // FileTypeAudio is for audio-only files (no video)
FileTypeVideo FileType = 3 // FileTypeVideo is for files with audio + visual
+ FileTypeGifv FileType = 4 // FileTypeGifv is for short video-only files (20s or less, mp4, no audio).
)
// String returns a stringified, frontend API compatible form of FileType.
@@ -104,6 +105,8 @@ func (t FileType) String() string {
return "audio"
case FileTypeVideo:
return "video"
+ case FileTypeGifv:
+ return "gifv"
default:
panic("invalid filetype")
}
diff --git a/internal/media/ffmpeg.go b/internal/media/ffmpeg.go
index 72ee1bc33..eb6dd9263 100644
--- a/internal/media/ffmpeg.go
+++ b/internal/media/ffmpeg.go
@@ -305,7 +305,15 @@ func (res *result) GetFileType() (gtsmodel.FileType, string) {
case "mov,mp4,m4a,3gp,3g2,mj2":
switch {
case len(res.video) > 0:
- return gtsmodel.FileTypeVideo, "mp4"
+ if len(res.audio) == 0 &&
+ res.duration <= 30 {
+ // Short, soundless
+ // video file aka gifv.
+ return gtsmodel.FileTypeGifv, "mp4"
+ } else {
+ // Video file (with or without audio).
+ return gtsmodel.FileTypeVideo, "mp4"
+ }
case len(res.audio) > 0 &&
res.audio[0].codec == "aac":
// m4a only supports [aac] audio.
diff --git a/internal/media/processingmedia.go b/internal/media/processingmedia.go
index 504cda11e..1d286bda7 100644
--- a/internal/media/processingmedia.go
+++ b/internal/media/processingmedia.go
@@ -202,7 +202,8 @@ func (p *ProcessingMedia) store(ctx context.Context) error {
switch p.media.Type {
case gtsmodel.FileTypeImage,
- gtsmodel.FileTypeVideo:
+ gtsmodel.FileTypeVideo,
+ gtsmodel.FileTypeGifv:
// Attempt to clean as metadata from file as possible.
if err := clearMetadata(ctx, temppath); err != nil {
return gtserror.Newf("error cleaning metadata: %w", err)
diff --git a/web/source/frontend/index.js b/web/source/frontend/index.js
index 74cb28460..b88c64680 100644
--- a/web/source/frontend/index.js
+++ b/web/source/frontend/index.js
@@ -26,6 +26,8 @@ const Prism = require("./prism.js");
Prism.manual = true;
Prism.highlightAll();
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
+
let [_, _user, type, id] = window.location.pathname.split("/");
if (type == "statuses") {
let firstStatus = document.getElementsByClassName("thread")[0].children[0];
@@ -49,9 +51,12 @@ new PhotoswipeCaptionPlugin(lightbox, {
lightbox.addFilter('itemData', (item) => {
const el = item.element;
- if (el && el.classList.contains("plyr-video")) {
+ if (
+ el &&
+ el.classList.contains("plyr-video") &&
+ el._plyrContainer !== undefined
+ ) {
const parentNode = el._plyrContainer.parentNode;
-
return {
alt: el.getAttribute("alt"),
_video: {
@@ -118,13 +123,14 @@ dynamicSpoiler("text-spoiler", (spoiler) => {
dynamicSpoiler("media-spoiler", (spoiler) => {
const eye = spoiler.querySelector(".eye.button");
const video = spoiler.querySelector(".plyr-video");
+ const loopingAuto = !reduceMotion.matches && video != null && video.classList.contains("gifv");
return () => {
if (spoiler.open) {
eye.setAttribute("aria-label", "Hide media");
} else {
eye.setAttribute("aria-label", "Show media");
- if (video) {
+ if (video && !loopingAuto) {
video.pause();
}
}
@@ -132,6 +138,22 @@ dynamicSpoiler("media-spoiler", (spoiler) => {
});
Array.from(document.getElementsByClassName("plyr-video")).forEach((video) => {
+ const loopingAuto = !reduceMotion.matches && video.classList.contains("gifv");
+
+ if (loopingAuto) {
+ // If we're able to play this as a
+ // looping gifv, then do so, else fall
+ // back to user-controllable video player.
+ video.draggable = false;
+ video.autoplay = true;
+ video.loop = true;
+ video.classList.remove("photoswipe-slide");
+ video.classList.remove("plry-video");
+ video.load();
+ video.play();
+ return;
+ }
+
let player = new Plyr(video, {
title: video.title,
settings: ["loop"],
diff --git a/web/template/status_attachments.tmpl b/web/template/status_attachments.tmpl
index 8e05aafd8..ee564d934 100644
--- a/web/template/status_attachments.tmpl
+++ b/web/template/status_attachments.tmpl
@@ -99,7 +99,7 @@ media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{
- {{- if eq .Type "video" }}
+ {{- if or (eq .Type "video") (eq .Type "gifv") }}
{{- include "videoPreview" $media | indent 4 }}
{{- else if eq .Type "image" }}
{{- include "imagePreview" $media | indent 4 }}
@@ -107,11 +107,17 @@ media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{
{{- include "audioPreview" $media | indent 4 }}
{{- end }}
- {{- if eq .Type "video" }}
+ {{- if or (eq .Type "video") (eq .Type "gifv") }}