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%;
|
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 {
|
&__spinner-container {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
fakeAttachment,
|
fakeAttachment,
|
||||||
fakeThumbnail,
|
fakeThumbnail,
|
||||||
} from '../test-both/helpers/fakeAttachment';
|
} from '../test-both/helpers/fakeAttachment';
|
||||||
|
import { VIDEO_MP4 } from '../types/MIME';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -87,3 +88,13 @@ story.add('Broken Image (thumbnail)', () => (
|
||||||
isThumbnail
|
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
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Blurhash } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
|
|
||||||
|
@ -12,15 +12,17 @@ import { TextAttachment } from './TextAttachment';
|
||||||
import { ThemeType } from '../types/Util';
|
import { ThemeType } from '../types/Util';
|
||||||
import {
|
import {
|
||||||
defaultBlurHash,
|
defaultBlurHash,
|
||||||
isDownloaded,
|
|
||||||
hasNotResolved,
|
hasNotResolved,
|
||||||
|
isDownloaded,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
|
isGIF,
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||||
|
import { isVideoTypeSupported } from '../util/GoogleChrome';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
readonly attachment?: AttachmentType;
|
readonly attachment?: AttachmentType;
|
||||||
i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly isThumbnail?: boolean;
|
readonly isThumbnail?: boolean;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly moduleClassName?: string;
|
readonly moduleClassName?: string;
|
||||||
|
@ -37,8 +39,6 @@ export const StoryImage = ({
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
storyId,
|
storyId,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
const [attachmentBroken, setAttachmentBroken] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const shouldDownloadAttachment =
|
const shouldDownloadAttachment =
|
||||||
!isDownloaded(attachment) && !isDownloading(attachment);
|
!isDownloaded(attachment) && !isDownloading(attachment);
|
||||||
|
|
||||||
|
@ -54,6 +54,7 @@ export const StoryImage = ({
|
||||||
|
|
||||||
const isPending = Boolean(attachment.pending) && !attachment.textAttachment;
|
const isPending = Boolean(attachment.pending) && !attachment.textAttachment;
|
||||||
const isNotReadyToShow = hasNotResolved(attachment) || isPending;
|
const isNotReadyToShow = hasNotResolved(attachment) || isPending;
|
||||||
|
const isSupportedVideo = isVideoTypeSupported(attachment.contentType);
|
||||||
|
|
||||||
const getClassName = getClassNamesFor('StoryImage', moduleClassName);
|
const getClassName = getClassNamesFor('StoryImage', moduleClassName);
|
||||||
|
|
||||||
|
@ -70,19 +71,24 @@ export const StoryImage = ({
|
||||||
width={attachment.width}
|
width={attachment.width}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (attachmentBroken) {
|
} else if (!isThumbnail && isSupportedVideo) {
|
||||||
|
const shouldLoop = isGIF(attachment ? [attachment] : undefined);
|
||||||
|
|
||||||
storyElement = (
|
storyElement = (
|
||||||
<div
|
<video
|
||||||
aria-label={i18n('StoryImage__error')}
|
autoPlay
|
||||||
className="StoryImage__error"
|
className={getClassName('__image')}
|
||||||
/>
|
controls={false}
|
||||||
|
loop={shouldLoop}
|
||||||
|
>
|
||||||
|
<source src={attachment.url} />
|
||||||
|
</video>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
storyElement = (
|
storyElement = (
|
||||||
<img
|
<img
|
||||||
alt={label}
|
alt={label}
|
||||||
className={getClassName('__image')}
|
className={getClassName('__image')}
|
||||||
onError={() => setAttachmentBroken(true)}
|
|
||||||
src={
|
src={
|
||||||
isThumbnail && attachment.thumbnail
|
isThumbnail && attachment.thumbnail
|
||||||
? attachment.thumbnail.url
|
? attachment.thumbnail.url
|
||||||
|
|
|
@ -15,12 +15,11 @@ import { Intl } from './Intl';
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
import { StoryImage } from './StoryImage';
|
import { StoryImage } from './StoryImage';
|
||||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||||
import { isDownloaded, isDownloading } from '../types/Attachment';
|
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
|
import { getStoryDuration } from '../util/getStoryDuration';
|
||||||
|
import { isDownloaded, isDownloading } from '../types/Attachment';
|
||||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
|
|
||||||
const STORY_DURATION = 5000;
|
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
group?: ConversationType;
|
group?: ConversationType;
|
||||||
|
@ -72,6 +71,7 @@ export const StoryViewer = ({
|
||||||
views,
|
views,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||||
|
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||||
|
|
||||||
const visibleStory = stories[currentStoryIndex];
|
const visibleStory = stories[currentStoryIndex];
|
||||||
|
|
||||||
|
@ -120,10 +120,25 @@ export const StoryViewer = ({
|
||||||
}
|
}
|
||||||
}, [currentStoryIndex, onPrevUserStories]);
|
}, [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(() => ({
|
const [styles, spring] = useSpring(() => ({
|
||||||
config: {
|
|
||||||
duration: STORY_DURATION,
|
|
||||||
},
|
|
||||||
from: { width: 0 },
|
from: { width: 0 },
|
||||||
to: { width: 100 },
|
to: { width: 100 },
|
||||||
loop: true,
|
loop: true,
|
||||||
|
@ -133,6 +148,9 @@ export const StoryViewer = ({
|
||||||
// that this useEffect should run whenever the story changes.
|
// that this useEffect should run whenever the story changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
spring.start({
|
spring.start({
|
||||||
|
config: {
|
||||||
|
duration: storyDuration,
|
||||||
|
},
|
||||||
from: { width: 0 },
|
from: { width: 0 },
|
||||||
to: { width: 100 },
|
to: { width: 100 },
|
||||||
onRest: {
|
onRest: {
|
||||||
|
@ -147,7 +165,7 @@ export const StoryViewer = ({
|
||||||
return () => {
|
return () => {
|
||||||
spring.stop();
|
spring.stop();
|
||||||
};
|
};
|
||||||
}, [currentStoryIndex, showNextStory, spring]);
|
}, [currentStoryIndex, showNextStory, spring, storyDuration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasReplyModal) {
|
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
Add a link
Reference in a new issue