Repair video playback in viewer
This commit is contained in:
parent
42108c9ca9
commit
1a9547c98f
5 changed files with 112 additions and 29 deletions
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
59
ts/util/getStoryDuration.ts
Normal file
59
ts/util/getStoryDuration.ts
Normal 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;
|
||||
}
|
Loading…
Add table
Reference in a new issue