From 1a9547c98f5fecd807c276708a852acd017bc807 Mon Sep 17 00:00:00 2001
From: Josh Perez <60019601+josh-signal@users.noreply.github.com>
Date: Tue, 12 Apr 2022 15:29:30 -0400
Subject: [PATCH] Repair video playback in viewer
---
stylesheets/components/StoryImage.scss | 11 -----
ts/components/StoryImage.stories.tsx | 11 +++++
ts/components/StoryImage.tsx | 28 +++++++-----
ts/components/StoryViewer.tsx | 32 +++++++++++---
ts/util/getStoryDuration.ts | 59 ++++++++++++++++++++++++++
5 files changed, 112 insertions(+), 29 deletions(-)
create mode 100644 ts/util/getStoryDuration.ts
diff --git a/stylesheets/components/StoryImage.scss b/stylesheets/components/StoryImage.scss
index d2e8333f3..391d96302 100644
--- a/stylesheets/components/StoryImage.scss
+++ b/stylesheets/components/StoryImage.scss
@@ -23,17 +23,6 @@
width: 100%;
}
- &__error {
- height: 100%;
- max-width: 140px;
- width: 100%;
-
- @include color-svg(
- '../images/full-screen-flow/alert-outline.svg',
- $color-gray-25
- );
- }
-
&__spinner-container {
align-items: center;
display: flex;
diff --git a/ts/components/StoryImage.stories.tsx b/ts/components/StoryImage.stories.tsx
index b527affaf..7c1267b03 100644
--- a/ts/components/StoryImage.stories.tsx
+++ b/ts/components/StoryImage.stories.tsx
@@ -14,6 +14,7 @@ import {
fakeAttachment,
fakeThumbnail,
} from '../test-both/helpers/fakeAttachment';
+import { VIDEO_MP4 } from '../types/MIME';
const i18n = setupI18n('en', enMessages);
@@ -87,3 +88,13 @@ story.add('Broken Image (thumbnail)', () => (
isThumbnail
/>
));
+
+story.add('Video', () => (
+
+));
diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx
index 707492872..ffc370f2b 100644
--- a/ts/components/StoryImage.tsx
+++ b/ts/components/StoryImage.tsx
@@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, { useEffect, useState } from 'react';
+import React, { useEffect } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
@@ -12,15 +12,17 @@ import { TextAttachment } from './TextAttachment';
import { ThemeType } from '../types/Util';
import {
defaultBlurHash,
- isDownloaded,
hasNotResolved,
+ isDownloaded,
isDownloading,
+ isGIF,
} from '../types/Attachment';
import { getClassNamesFor } from '../util/getClassNamesFor';
+import { isVideoTypeSupported } from '../util/GoogleChrome';
export type PropsType = {
readonly attachment?: AttachmentType;
- i18n: LocalizerType;
+ readonly i18n: LocalizerType;
readonly isThumbnail?: boolean;
readonly label: string;
readonly moduleClassName?: string;
@@ -37,8 +39,6 @@ export const StoryImage = ({
queueStoryDownload,
storyId,
}: PropsType): JSX.Element | null => {
- const [attachmentBroken, setAttachmentBroken] = useState(false);
-
const shouldDownloadAttachment =
!isDownloaded(attachment) && !isDownloading(attachment);
@@ -54,6 +54,7 @@ export const StoryImage = ({
const isPending = Boolean(attachment.pending) && !attachment.textAttachment;
const isNotReadyToShow = hasNotResolved(attachment) || isPending;
+ const isSupportedVideo = isVideoTypeSupported(attachment.contentType);
const getClassName = getClassNamesFor('StoryImage', moduleClassName);
@@ -70,19 +71,24 @@ export const StoryImage = ({
width={attachment.width}
/>
);
- } else if (attachmentBroken) {
+ } else if (!isThumbnail && isSupportedVideo) {
+ const shouldLoop = isGIF(attachment ? [attachment] : undefined);
+
storyElement = (
-
+
);
} else {
storyElement = (
setAttachmentBroken(true)}
src={
isThumbnail && attachment.thumbnail
? attachment.thumbnail.url
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx
index e557c61d3..46ed6cff3 100644
--- a/ts/components/StoryViewer.tsx
+++ b/ts/components/StoryViewer.tsx
@@ -15,12 +15,11 @@ import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
-import { isDownloaded, isDownloading } from '../types/Attachment';
import { getAvatarColor } from '../types/Colors';
+import { getStoryDuration } from '../util/getStoryDuration';
+import { isDownloaded, isDownloading } from '../types/Attachment';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
-const STORY_DURATION = 5000;
-
export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
group?: ConversationType;
@@ -72,6 +71,7 @@ export const StoryViewer = ({
views,
}: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
+ const [storyDuration, setStoryDuration] = useState();
const visibleStory = stories[currentStoryIndex];
@@ -120,10 +120,25 @@ export const StoryViewer = ({
}
}, [currentStoryIndex, onPrevUserStories]);
+ useEffect(() => {
+ let shouldCancel = false;
+ (async function hydrateStoryDuration() {
+ if (!attachment) {
+ return;
+ }
+ const duration = await getStoryDuration(attachment);
+ if (shouldCancel) {
+ return;
+ }
+ setStoryDuration(duration);
+ })();
+
+ return () => {
+ shouldCancel = true;
+ };
+ }, [attachment]);
+
const [styles, spring] = useSpring(() => ({
- config: {
- duration: STORY_DURATION,
- },
from: { width: 0 },
to: { width: 100 },
loop: true,
@@ -133,6 +148,9 @@ export const StoryViewer = ({
// that this useEffect should run whenever the story changes.
useEffect(() => {
spring.start({
+ config: {
+ duration: storyDuration,
+ },
from: { width: 0 },
to: { width: 100 },
onRest: {
@@ -147,7 +165,7 @@ export const StoryViewer = ({
return () => {
spring.stop();
};
- }, [currentStoryIndex, showNextStory, spring]);
+ }, [currentStoryIndex, showNextStory, spring, storyDuration]);
useEffect(() => {
if (hasReplyModal) {
diff --git a/ts/util/getStoryDuration.ts b/ts/util/getStoryDuration.ts
new file mode 100644
index 000000000..43c9b6b80
--- /dev/null
+++ b/ts/util/getStoryDuration.ts
@@ -0,0 +1,59 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { AttachmentType } from '../types/Attachment';
+import { isGIF, isVideo } from '../types/Attachment';
+import { count } from './grapheme';
+import { SECOND } from './durations';
+
+const DEFAULT_DURATION = 5 * SECOND;
+const MAX_VIDEO_DURATION = 30 * SECOND;
+const MIN_TEXT_DURATION = 3 * SECOND;
+
+export async function getStoryDuration(
+ attachment: AttachmentType
+): Promise {
+ if (isGIF([attachment]) || isVideo([attachment])) {
+ const videoEl = document.createElement('video');
+ if (!attachment.url) {
+ return DEFAULT_DURATION;
+ }
+ videoEl.src = attachment.url;
+
+ await new Promise(resolve => {
+ function resolveAndRemove() {
+ resolve();
+ videoEl.removeEventListener('loadedmetadata', resolveAndRemove);
+ }
+
+ videoEl.addEventListener('loadedmetadata', resolveAndRemove);
+ });
+
+ const duration = Math.ceil(videoEl.duration * SECOND);
+
+ if (isGIF([attachment])) {
+ // GIFs: Loop gifs 3 times or play for 5 seconds, whichever is longer.
+ return Math.min(
+ Math.max(duration * 3, DEFAULT_DURATION),
+ MAX_VIDEO_DURATION
+ );
+ }
+
+ // Video max duration: 30 seconds
+ return Math.min(duration, MAX_VIDEO_DURATION);
+ }
+
+ if (attachment.textAttachment && attachment.textAttachment.text) {
+ // Minimum 3 seconds. +1 second for every 15 characters past the first
+ // 15 characters (round up).
+ // For text stories that include a link, +2 seconds to the playback time.
+ const length = count(attachment.textAttachment.text);
+ const additionalSeconds = (Math.ceil(length / 15) - 1) * SECOND;
+ const linkPreviewSeconds = attachment.textAttachment.preview
+ ? 2 * SECOND
+ : 0;
+ return MIN_TEXT_DURATION + additionalSeconds + linkPreviewSeconds;
+ }
+
+ return DEFAULT_DURATION;
+}