Stories: Improve mobile design, animation and preloading
This commit is contained in:
parent
1f029966ae
commit
7ab7609804
@ -18,14 +18,18 @@ import {
|
||||
selectChat, selectIsCurrentUserPremium,
|
||||
selectPeer,
|
||||
selectPeerStories, selectPeerStory,
|
||||
selectPerformanceSettingsValue,
|
||||
selectTabState, selectUser,
|
||||
} from '../../global/selectors';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import captureKeyboardListeners from '../../util/captureKeyboardListeners';
|
||||
import { formatMediaDuration, formatRelativeTime } from '../../util/dateFormat';
|
||||
import download from '../../util/download';
|
||||
import { round } from '../../util/math';
|
||||
import { getServerTime } from '../../util/serverTime';
|
||||
import { IS_SAFARI } from '../../util/windowEnvironment';
|
||||
import renderText from '../common/helpers/renderText';
|
||||
import { BASE_STORY_HEIGHT, BASE_STORY_WIDTH } from './helpers/dimensions';
|
||||
import { PRIMARY_VIDEO_MIME, SECONDARY_VIDEO_MIME } from './helpers/videoFormats';
|
||||
|
||||
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
||||
@ -33,7 +37,6 @@ import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
|
||||
import useBackgroundMode from '../../hooks/useBackgroundMode';
|
||||
import useCanvasBlur from '../../hooks/useCanvasBlur';
|
||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
|
||||
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
import useLang from '../../hooks/useLang';
|
||||
@ -53,6 +56,7 @@ import DropdownMenu from '../ui/DropdownMenu';
|
||||
import MenuItem from '../ui/MenuItem';
|
||||
import OptimizedVideo from '../ui/OptimizedVideo';
|
||||
import Skeleton from '../ui/placeholder/Skeleton';
|
||||
import Transition from '../ui/Transition';
|
||||
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
|
||||
import StoryCaption from './StoryCaption';
|
||||
import StoryFooter from './StoryFooter';
|
||||
@ -90,9 +94,10 @@ interface StateProps {
|
||||
areChatSettingsLoaded?: boolean;
|
||||
isCurrentUserPremium?: boolean;
|
||||
stealthMode: ApiStealthMode;
|
||||
withHeaderAnimation?: boolean;
|
||||
}
|
||||
|
||||
const VIDEO_MIN_READY_STATE = 4;
|
||||
const VIDEO_MIN_READY_STATE = IS_SAFARI ? 4 : 3;
|
||||
const SPACEBAR_CODE = 32;
|
||||
|
||||
const STEALTH_MODE_NOTIFICATION_DURATION = 4000;
|
||||
@ -120,6 +125,7 @@ function Story({
|
||||
onDelete,
|
||||
onClose,
|
||||
onReport,
|
||||
withHeaderAnimation,
|
||||
}: OwnProps & StateProps) {
|
||||
const {
|
||||
viewStory,
|
||||
@ -142,7 +148,6 @@ function Story({
|
||||
|
||||
const lang = useLang();
|
||||
const { isMobile } = useAppLayout();
|
||||
const [, setCurrentTime] = useCurrentTimeSignal();
|
||||
const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false);
|
||||
const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false);
|
||||
const [isStoryPlaying, markStoryPlaying, unmarkStoryPlaying] = useFlag(false);
|
||||
@ -171,7 +176,6 @@ function Story({
|
||||
} = useStoryProps(story, isCurrentUserPremium, isDropdownMenuOpen);
|
||||
|
||||
const isLoadedStory = story && 'content' in story;
|
||||
|
||||
const isChangelog = peerId === storyChangelogUserId;
|
||||
const isChannel = !isUserId(peerId);
|
||||
const isOut = isLoadedStory && story.isOut;
|
||||
@ -216,9 +220,11 @@ function Story({
|
||||
: undefined;
|
||||
|
||||
const shouldShowFooter = isLoadedStory && (isOut || isChannel);
|
||||
const headerAnimation = isMobile && withHeaderAnimation ? 'slideFade' : 'none';
|
||||
|
||||
const {
|
||||
shouldRender: shouldRenderSkeleton, transitionClassNames: skeletonTransitionClassNames,
|
||||
shouldRender: shouldRenderSkeleton,
|
||||
transitionClassNames: skeletonTransitionClassNames,
|
||||
} = useShowTransition(!hasFullData);
|
||||
|
||||
const {
|
||||
@ -239,8 +245,12 @@ function Story({
|
||||
} = useShowTransition(hasText && isCaptionExpanded);
|
||||
|
||||
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true);
|
||||
const {
|
||||
shouldRender: shouldRenderCaption,
|
||||
transitionClassNames: captionAppearanceAnimationClassNames,
|
||||
} = useShowTransition(hasText || hasForwardInfo);
|
||||
|
||||
useStreaming(videoRef, fullMediaData, PRIMARY_VIDEO_MIME);
|
||||
const isStreamingSupported = useStreaming(videoRef, fullMediaData, PRIMARY_VIDEO_MIME);
|
||||
|
||||
useStoryPreloader(peerId, storyId);
|
||||
|
||||
@ -308,11 +318,18 @@ function Story({
|
||||
onTouchEnd: handleLongPressTouchEnd,
|
||||
} = useLongPress(handleLongPressStart, handleLongPressEnd);
|
||||
|
||||
const isUnsupported = useUnsupportedMedia(videoRef, undefined, !isVideo || !fullMediaData);
|
||||
const isUnsupported = useUnsupportedMedia(
|
||||
videoRef,
|
||||
undefined,
|
||||
!isVideo || !fullMediaData || isStreamingSupported,
|
||||
);
|
||||
|
||||
const hasAllData = fullMediaData && (!altMediaHash || altMediaData);
|
||||
// Play story after media has been downloaded
|
||||
useEffect(() => { if (hasAllData && !isUnsupported) handlePlayStory(); }, [hasAllData, isUnsupported]);
|
||||
useEffect(() => {
|
||||
if (hasAllData && !isUnsupported) handlePlayStory();
|
||||
}, [hasAllData, isUnsupported]);
|
||||
|
||||
useBackgroundMode(unmarkAppFocused, markAppFocused);
|
||||
|
||||
useEffect(() => {
|
||||
@ -371,7 +388,9 @@ function Story({
|
||||
if (
|
||||
!isPausedBySpacebar || isCaptionExpanded || isComposerHasFocus
|
||||
|| shouldForcePause || !isAppFocused || isPausedByLongPress
|
||||
) return;
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
prevIsCaptionExpanded !== isCaptionExpanded
|
||||
@ -395,21 +414,21 @@ function Story({
|
||||
});
|
||||
|
||||
const handleOpenPrevStory = useLastCallback(() => {
|
||||
setCurrentTime(0);
|
||||
openPreviousStory();
|
||||
});
|
||||
|
||||
const handleOpenNextStory = useLastCallback(() => {
|
||||
setCurrentTime(0);
|
||||
openNextStory();
|
||||
});
|
||||
|
||||
const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget;
|
||||
if (video.readyState >= VIDEO_MIN_READY_STATE) {
|
||||
setCurrentTime(video.currentTime);
|
||||
markStoryPlaying();
|
||||
} else {
|
||||
unmarkStoryPlaying();
|
||||
}
|
||||
if (duration && video.currentTime >= duration) {
|
||||
if (duration && round(video.currentTime, 2) >= round(duration, 2)) {
|
||||
handleOpenNextStory();
|
||||
}
|
||||
});
|
||||
@ -434,7 +453,6 @@ function Story({
|
||||
});
|
||||
|
||||
const handleDeleteStoryClick = useLastCallback(() => {
|
||||
setCurrentTime(0);
|
||||
onDelete(story!);
|
||||
});
|
||||
|
||||
@ -597,15 +615,15 @@ function Story({
|
||||
);
|
||||
}
|
||||
|
||||
function renderSender() {
|
||||
function renderSenderInfo() {
|
||||
return (
|
||||
<div className={styles.sender}>
|
||||
<div className={styles.senderInfo}>
|
||||
<Avatar
|
||||
peer={peer}
|
||||
size="tiny"
|
||||
onClick={handleOpenChat}
|
||||
/>
|
||||
<div className={styles.senderInfo}>
|
||||
<div className={styles.senderMeta}>
|
||||
<span onClick={handleOpenChat} className={styles.senderName}>
|
||||
{renderText(getSenderTitle(lang, peer) || '')}
|
||||
</span>
|
||||
@ -631,6 +649,16 @@ function Story({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSender() {
|
||||
return (
|
||||
<div className={styles.sender}>
|
||||
<Transition activeKey={Number(peerId)} name={headerAnimation} className={styles.senderInfoTransition}>
|
||||
{renderSenderInfo()}
|
||||
</Transition>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{renderStoryPrivacyButton()}
|
||||
@ -713,10 +741,11 @@ function Story({
|
||||
<canvas ref={thumbRef} className={styles.thumbnail} />
|
||||
{previewBlobUrl && (
|
||||
<img
|
||||
key={`preview-${storyId}`}
|
||||
src={previewBlobUrl}
|
||||
draggable={false}
|
||||
alt=""
|
||||
className={buildClassName(styles.media, previewTransitionClassNames)}
|
||||
className={buildClassName(styles.media, styles.mediaPreview, previewTransitionClassNames)}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderSkeleton && (
|
||||
@ -733,14 +762,16 @@ function Story({
|
||||
{isVideo && fullMediaData && (
|
||||
<OptimizedVideo
|
||||
ref={videoRef}
|
||||
key={`video-${storyId}`}
|
||||
className={buildClassName(styles.media, mediaTransitionClassNames)}
|
||||
canPlay={isStoryPlaybackRequested}
|
||||
muted={isMuted}
|
||||
width={BASE_STORY_WIDTH}
|
||||
height={BASE_STORY_HEIGHT}
|
||||
draggable={false}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
isPriority
|
||||
onPlaying={markStoryPlaying}
|
||||
onPause={unmarkStoryPlaying}
|
||||
onWaiting={unmarkStoryPlaying}
|
||||
disableRemotePlayback
|
||||
@ -784,15 +815,15 @@ function Story({
|
||||
aria-label={lang('Close')}
|
||||
/>
|
||||
)}
|
||||
{hasText && <div className={styles.captionGradient} />}
|
||||
{(hasText || hasForwardInfo) && (
|
||||
{hasText && <div className={buildClassName(styles.captionGradient, captionAppearanceAnimationClassNames)} />}
|
||||
{shouldRenderCaption && (
|
||||
<StoryCaption
|
||||
key={`caption-${storyId}-${peerId}`}
|
||||
story={story as ApiStory}
|
||||
isExpanded={isCaptionExpanded}
|
||||
onExpand={expandCaption}
|
||||
onFold={foldCaption}
|
||||
className={appearanceAnimationClassNames}
|
||||
className={captionAppearanceAnimationClassNames}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderComposer && (
|
||||
@ -850,6 +881,7 @@ export default memo(withGlobal<OwnProps>((global, {
|
||||
const forwardSenderId = forwardInfo?.fromPeerId
|
||||
|| mediaAreas?.find((area): area is ApiMediaAreaChannelPost => area.type === 'channelPost')?.channelId;
|
||||
const forwardSender = forwardSenderId ? selectPeer(global, forwardSenderId) : undefined;
|
||||
const withHeaderAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations');
|
||||
|
||||
return {
|
||||
peer: (user || chat)!,
|
||||
@ -864,5 +896,6 @@ export default memo(withGlobal<OwnProps>((global, {
|
||||
isChatExist: Boolean(chat),
|
||||
areChatSettingsLoaded: Boolean(chat?.settings),
|
||||
stealthMode: global.stories.stealthMode,
|
||||
withHeaderAnimation,
|
||||
};
|
||||
})(Story));
|
||||
|
||||
@ -8,10 +8,12 @@ import type { StoryViewerOrigin } from '../../types';
|
||||
|
||||
import { getSenderTitle, getStoryMediaHash } from '../../global/helpers';
|
||||
import { selectTabState } from '../../global/selectors';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import renderText from '../common/helpers/renderText';
|
||||
|
||||
import useLang from '../../hooks/useLang';
|
||||
import useMedia from '../../hooks/useMedia';
|
||||
import useShowTransition from '../../hooks/useShowTransition';
|
||||
|
||||
import Avatar from '../common/Avatar';
|
||||
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
|
||||
@ -33,6 +35,7 @@ function StoryPreview({
|
||||
}: OwnProps & StateProps) {
|
||||
const { openStoryViewer, loadPeerSkippedStories } = getActions();
|
||||
const lang = useLang();
|
||||
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true);
|
||||
|
||||
const story = useMemo<ApiTypeStory | undefined>(() => {
|
||||
if (!peerStories) {
|
||||
@ -76,13 +79,15 @@ function StoryPreview({
|
||||
)}
|
||||
{isLoaded && <MediaAreaOverlay story={story} />}
|
||||
|
||||
<div className={styles.content}>
|
||||
<Avatar
|
||||
peer={peer}
|
||||
withStory
|
||||
storyViewerMode="disabled"
|
||||
/>
|
||||
<div className={styles.name}>{renderText(getSenderTitle(lang, peer) || '')}</div>
|
||||
<div className={buildClassName(styles.content, appearanceAnimationClassNames)}>
|
||||
<div className={styles.contentInner}>
|
||||
<Avatar
|
||||
peer={peer}
|
||||
withStory
|
||||
storyViewerMode="disabled"
|
||||
/>
|
||||
<div className={styles.name}>{renderText(getSenderTitle(lang, peer) || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -9,7 +9,11 @@ import type { RealTouchEvent } from '../../util/captureEvents';
|
||||
import { ANIMATION_END_DELAY, EDITABLE_STORY_INPUT_ID } from '../../config';
|
||||
import { requestMutation } from '../../lib/fasterdom/fasterdom';
|
||||
import { getStoryKey } from '../../global/helpers';
|
||||
import { selectIsStoryViewerOpen, selectPeer, selectTabState } from '../../global/selectors';
|
||||
import {
|
||||
selectIsStoryViewerOpen,
|
||||
selectPeer,
|
||||
selectTabState,
|
||||
} from '../../global/selectors';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
import buildStyle from '../../util/buildStyle';
|
||||
import {
|
||||
@ -312,7 +316,7 @@ function StorySlides({
|
||||
offsetY = clamp(dragOffsetY, -limit, limit);
|
||||
if (offsetY > 0) {
|
||||
requestMutation(() => {
|
||||
current.style.setProperty('--slide-translate-y', `${-offsetY}px`);
|
||||
current.style.setProperty('--slide-translate-y', `${offsetY * (isMobile ? 1 : -1)}px`);
|
||||
});
|
||||
}
|
||||
if (event.type === 'wheel' && Math.abs(offsetY) > SWIPE_Y_THRESHOLD * 2) {
|
||||
@ -323,9 +327,10 @@ function StorySlides({
|
||||
},
|
||||
onRelease,
|
||||
});
|
||||
}, [isOpen, renderingPeerId, onClose, windowWidth, windowHeight]);
|
||||
}, [isOpen, onClose, windowWidth, windowHeight, isMobile, renderingPeerId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isMobile) return;
|
||||
const transformX = calculateTransformX();
|
||||
|
||||
Object.entries(rendersRef.current).forEach(([peerId, { current }]) => {
|
||||
@ -341,7 +346,6 @@ function StorySlides({
|
||||
}
|
||||
|
||||
const getScale = () => {
|
||||
if (isMobile) return '1';
|
||||
if (currentPeerId === peerId) {
|
||||
return String(slideSizes.toActiveScale);
|
||||
}
|
||||
@ -353,11 +357,11 @@ function StorySlides({
|
||||
|
||||
let offsetY = 0;
|
||||
if (peerId === renderingPeerId) {
|
||||
if (!isMobile) offsetY = -ACTIVE_SLIDE_VERTICAL_CORRECTION_REM * slideSizes.fromActiveScale;
|
||||
offsetY = -ACTIVE_SLIDE_VERTICAL_CORRECTION_REM * slideSizes.fromActiveScale;
|
||||
current.classList.add(styles.slideAnimationFromActive);
|
||||
}
|
||||
if (peerId === currentPeerId) {
|
||||
if (!isMobile) offsetY = ACTIVE_SLIDE_VERTICAL_CORRECTION_REM;
|
||||
offsetY = ACTIVE_SLIDE_VERTICAL_CORRECTION_REM;
|
||||
current.classList.add(styles.slideAnimationToActive);
|
||||
}
|
||||
|
||||
@ -366,7 +370,33 @@ function StorySlides({
|
||||
current.style.setProperty('--slide-translate-y', `${offsetY}rem`);
|
||||
current.style.setProperty('--slide-translate-scale', getScale());
|
||||
});
|
||||
}, [currentPeerId, getIsAnimating, renderingPeerId, isMobile, slideSizes]);
|
||||
}, [currentPeerId, getIsAnimating, renderingPeerId, slideSizes, isMobile]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef}>
|
||||
<div
|
||||
className={styles.mobileSlide}
|
||||
ref={(ref) => setRef(ref, renderingPeerId!)}
|
||||
>
|
||||
<Story
|
||||
peerId={renderingPeerId!}
|
||||
storyId={renderingStoryId!}
|
||||
onDelete={onDelete}
|
||||
dimensions={slideSizes.activeSlide}
|
||||
isPrivateStories={renderingIsPrivate}
|
||||
isArchivedStories={renderingIsArchive}
|
||||
isReportModalOpen={isReportModalOpen}
|
||||
isDeleteModalOpen={isDeleteModalOpen}
|
||||
isSingleStory={isSingleStory}
|
||||
getIsAnimating={getIsAnimating}
|
||||
onClose={onClose}
|
||||
onReport={onReport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStoryPreview(peerId: string, index: number, position: number) {
|
||||
const style = buildStyle(
|
||||
@ -395,7 +425,7 @@ function StorySlides({
|
||||
}
|
||||
|
||||
function renderStory(peerId: string) {
|
||||
const style = buildStyle(
|
||||
const style = isMobile ? undefined : buildStyle(
|
||||
`width: ${slideSizes.activeSlide.width}px`,
|
||||
`--slide-media-height: ${slideSizes.activeSlide.height}px`,
|
||||
);
|
||||
|
||||
@ -113,12 +113,20 @@
|
||||
}
|
||||
|
||||
.slideAnimationFromActive {
|
||||
.storyHeader,
|
||||
.composer,
|
||||
.caption,
|
||||
.storyIndicators {
|
||||
.captionGradient,
|
||||
.captionBackdrop{
|
||||
transition: opacity 250ms ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
.media:not(.mediaPreview) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.mediaPreview {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide {
|
||||
@ -147,29 +155,18 @@
|
||||
--slide-x: calc(#{$offset} * var(--story-viewer-scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.mobileSlide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(var(--slide-translate-y, 0px));
|
||||
}
|
||||
|
||||
.slidePreview {
|
||||
overflow: hidden;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
|
||||
&::before {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.slideAnimationToActive::before {
|
||||
transition: opacity 350ms ease-in-out !important;
|
||||
opacity: 0;
|
||||
@ -185,19 +182,6 @@
|
||||
height: calc(var(--slide-media-height) + 3.5rem);
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
display: block;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
transform: translate3d(0, calc(var(--slide-translate-y, 0px) * -1), 0);
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
@ -210,13 +194,6 @@
|
||||
opacity: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (min-width: 600.001px) {
|
||||
&.slideAnimationFromActive::before {
|
||||
transition: opacity 350ms ease-in-out !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slideInner {
|
||||
@ -276,6 +253,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@ -371,8 +359,17 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.senderInfoTransition {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.senderInfo {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.senderMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
@ -605,7 +602,7 @@
|
||||
left: 0;
|
||||
margin-bottom: 0;
|
||||
z-index: 3;
|
||||
transition: transform var(--layer-transition);
|
||||
transition: transform var(--layer-transition), opacity 0.15s ease;
|
||||
|
||||
&:global(.Composer) {
|
||||
--base-height: 3rem;
|
||||
|
||||
@ -2,6 +2,8 @@ import type { IDimensions } from '../../../global/types';
|
||||
|
||||
import { roundToNearestEven } from '../../../util/math';
|
||||
|
||||
export const BASE_STORY_WIDTH = 720;
|
||||
export const BASE_STORY_HEIGHT = 1280;
|
||||
const BASE_SCREEN_WIDTH = 1200;
|
||||
const BASE_SCREEN_HEIGHT = 800;
|
||||
const BASE_ACTIVE_SLIDE_WIDTH = 405;
|
||||
|
||||
@ -96,7 +96,7 @@ export function animateClosing(
|
||||
const { mediaEl: toImage } = getNodes(origin, userId);
|
||||
|
||||
const fromImage = document.getElementById('StoryViewer')!.querySelector<HTMLImageElement>(
|
||||
`.${styles.activeSlide} .${styles.media}`,
|
||||
`.${styles.mobileSlide} .${styles.media}, .${styles.activeSlide} .${styles.media}`,
|
||||
);
|
||||
if (!fromImage || !toImage) {
|
||||
return;
|
||||
|
||||
@ -5,6 +5,7 @@ import { ApiMediaFormat } from '../../../api/types';
|
||||
|
||||
import { getStoryMediaHash } from '../../../global/helpers';
|
||||
import { selectPeerStories } from '../../../global/selectors';
|
||||
import { preloadImage } from '../../../util/files';
|
||||
import * as mediaLoader from '../../../util/mediaLoader';
|
||||
import { getProgressiveUrl } from '../../../util/mediaLoader';
|
||||
import { makeProgressiveLoader } from '../../../util/progressieveLoader';
|
||||
@ -44,6 +45,9 @@ function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) {
|
||||
if (format === ApiMediaFormat.Progressive) {
|
||||
preloadProgressive(result);
|
||||
}
|
||||
if (format === ApiMediaFormat.BlobUrl) {
|
||||
preloadImage(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -60,8 +64,9 @@ function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) {
|
||||
|
||||
function findIdsAroundCurrentId<T>(ids: T[], currentId: T, aroundAmount: number): T[] {
|
||||
const currentIndex = ids.indexOf(currentId);
|
||||
|
||||
return ids.slice(currentIndex - aroundAmount, currentIndex + aroundAmount);
|
||||
const start = Math.max(currentIndex - aroundAmount, 0);
|
||||
const end = Math.min(currentIndex + aroundAmount, ids.length);
|
||||
return ids.slice(start, end);
|
||||
}
|
||||
|
||||
function getPreloadMediaHashes(peerId: string, storyId: number) {
|
||||
@ -83,11 +88,13 @@ function getPreloadMediaHashes(peerId: string, storyId: number) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isVideo = Boolean(story.content.video);
|
||||
|
||||
// Media
|
||||
mediaHashes.push({
|
||||
hash: getStoryMediaHash(story, 'full'),
|
||||
format: story.content.video ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl,
|
||||
isStream: checkIfStreamingSupported(PRIMARY_VIDEO_MIME),
|
||||
format: isVideo ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl,
|
||||
isStream: isVideo && checkIfStreamingSupported(PRIMARY_VIDEO_MIME),
|
||||
});
|
||||
// Thumbnail
|
||||
mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl });
|
||||
|
||||
@ -76,26 +76,26 @@ export function useStreaming(videoRef: RefObject<HTMLVideoElement>, url?: string
|
||||
});
|
||||
|
||||
return () => {
|
||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||
if (mediaSource.readyState === 'open') {
|
||||
endOfStream(mediaSource);
|
||||
}
|
||||
URL.revokeObjectURL(video.src);
|
||||
requestMutation(() => {
|
||||
const src = video.src;
|
||||
video.pause();
|
||||
video.src = '';
|
||||
applyStyles(video, {
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
});
|
||||
video.load();
|
||||
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||
if (mediaSource.readyState === 'open') {
|
||||
endOfStream(mediaSource);
|
||||
}
|
||||
URL.revokeObjectURL(src);
|
||||
});
|
||||
};
|
||||
}, [mimeType, url, videoRef]);
|
||||
|
||||
return checkIfStreamingSupported(mimeType);
|
||||
}
|
||||
|
||||
export function checkIfStreamingSupported(mimeType: string) {
|
||||
if (!IS_SAFARI) return false;
|
||||
const MS = getMediaSource();
|
||||
return Boolean(MS && MS.isTypeSupported(mimeType));
|
||||
export function checkIfStreamingSupported(mimeType?: string) {
|
||||
if (!IS_SAFARI || !mimeType) return false;
|
||||
return Boolean(getMediaSource()?.isTypeSupported(mimeType));
|
||||
}
|
||||
|
||||
function appendBuffer(sourceBuffer: SourceBuffer, buffer: ArrayBuffer) {
|
||||
|
||||
@ -1,40 +1,77 @@
|
||||
const DEFAULT_CHUNK_SIZE = 256 * 1024;
|
||||
import { ApiMediaFormat } from '../api/types';
|
||||
|
||||
import { callApi } from '../api/gramjs';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const DEFAULT_PART_SIZE = 0.25 * MB;
|
||||
const MAX_END_TO_CACHE = 5 * MB - 1; // We only cache the first 2 MB of each file
|
||||
|
||||
const bufferCache = new Map<string, ArrayBuffer>();
|
||||
const sizeCache = new Map<string, number>();
|
||||
const pendingRequests = new Map<string, Promise<{ arrayBuffer?: ArrayBuffer; fullSize?: number } | undefined>>();
|
||||
|
||||
export async function* makeProgressiveLoader(
|
||||
url: string,
|
||||
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||
start = 0,
|
||||
chunkSize = DEFAULT_PART_SIZE,
|
||||
): AsyncGenerator<ArrayBuffer, void, undefined> {
|
||||
let start = 0;
|
||||
let fileSize: number | undefined;
|
||||
const match = url.match(/fileSize=(\d+)/);
|
||||
let fileSize;
|
||||
if (match) {
|
||||
fileSize = match && Number(match[1]);
|
||||
} else {
|
||||
fileSize = sizeCache.get(url);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (fileSize && start >= fileSize) return;
|
||||
|
||||
let end = start + chunkSize - 1;
|
||||
if (fileSize && end > fileSize) {
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Range: `bytes=${start}-${end}` },
|
||||
});
|
||||
// Check if we have the chunk in memory
|
||||
const cacheKey = `${url}:${start}-${end}`;
|
||||
let arrayBuffer = bufferCache.get(cacheKey);
|
||||
|
||||
if (!res.ok) return;
|
||||
if (!arrayBuffer) {
|
||||
let request = pendingRequests.get(cacheKey);
|
||||
if (!request) {
|
||||
request = callApi('downloadMedia', {
|
||||
mediaFormat: ApiMediaFormat.Progressive,
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
// If fileSize is not yet defined, retrieve it from the first chunk's response
|
||||
if (!fileSize) {
|
||||
const contentRange = res.headers.get('Content-Range');
|
||||
const match = contentRange?.match(/\/(\d+)$/);
|
||||
fileSize = match ? Number(match[1]) : undefined;
|
||||
pendingRequests.set(cacheKey, request);
|
||||
}
|
||||
|
||||
if (!fileSize) return;
|
||||
const result = await request.finally(() => {
|
||||
pendingRequests.delete(cacheKey);
|
||||
});
|
||||
|
||||
if (!result?.arrayBuffer) return;
|
||||
|
||||
// If fileSize is not yet defined, retrieve it from the first chunk's response
|
||||
if (result.fullSize && !fileSize) {
|
||||
fileSize = result.fullSize;
|
||||
sizeCache.set(url, result.fullSize);
|
||||
}
|
||||
|
||||
// Store the chunk in memory
|
||||
arrayBuffer = result.arrayBuffer;
|
||||
|
||||
// Cache the first 2 MB of each file
|
||||
if (end <= MAX_END_TO_CACHE) {
|
||||
bufferCache.set(cacheKey, result.arrayBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Yield the chunk data
|
||||
const chunk = await res.arrayBuffer();
|
||||
yield chunk;
|
||||
yield arrayBuffer;
|
||||
|
||||
start = end + 1;
|
||||
|
||||
if (start >= fileSize) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user