Repair video playback in viewer

This commit is contained in:
Josh Perez 2022-04-12 15:29:30 -04:00 committed by GitHub
parent 42108c9ca9
commit 1a9547c98f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 112 additions and 29 deletions

View File

@ -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;

View File

@ -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', () => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
contentType: VIDEO_MP4,
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
})}
/>
));

View File

@ -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<boolean>(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 = (
<div
aria-label={i18n('StoryImage__error')}
className="StoryImage__error"
/>
<video
autoPlay
className={getClassName('__image')}
controls={false}
loop={shouldLoop}
>
<source src={attachment.url} />
</video>
);
} else {
storyElement = (
<img
alt={label}
className={getClassName('__image')}
onError={() => setAttachmentBroken(true)}
src={
isThumbnail && attachment.thumbnail
? attachment.thumbnail.url

View File

@ -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<number | undefined>();
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) {

View File

@ -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<number> {
if (isGIF([attachment]) || isVideo([attachment])) {
const videoEl = document.createElement('video');
if (!attachment.url) {
return DEFAULT_DURATION;
}
videoEl.src = attachment.url;
await new Promise<void>(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;
}