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 = ( {label} 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; +}