Stories: Improve mobile design, animation and preloading

This commit is contained in:
Alexander Zinchuk 2024-01-12 12:59:59 +01:00
parent 1f029966ae
commit 7ab7609804
9 changed files with 225 additions and 114 deletions

View File

@ -18,14 +18,18 @@ import {
selectChat, selectIsCurrentUserPremium, selectChat, selectIsCurrentUserPremium,
selectPeer, selectPeer,
selectPeerStories, selectPeerStory, selectPeerStories, selectPeerStory,
selectPerformanceSettingsValue,
selectTabState, selectUser, selectTabState, selectUser,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import { formatMediaDuration, formatRelativeTime } from '../../util/dateFormat'; import { formatMediaDuration, formatRelativeTime } from '../../util/dateFormat';
import download from '../../util/download'; import download from '../../util/download';
import { round } from '../../util/math';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { IS_SAFARI } from '../../util/windowEnvironment';
import renderText from '../common/helpers/renderText'; 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 { PRIMARY_VIDEO_MIME, SECONDARY_VIDEO_MIME } from './helpers/videoFormats';
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia'; import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
@ -33,7 +37,6 @@ import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
import useBackgroundMode from '../../hooks/useBackgroundMode'; import useBackgroundMode from '../../hooks/useBackgroundMode';
import useCanvasBlur from '../../hooks/useCanvasBlur'; import useCanvasBlur from '../../hooks/useCanvasBlur';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -53,6 +56,7 @@ import DropdownMenu from '../ui/DropdownMenu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import OptimizedVideo from '../ui/OptimizedVideo'; import OptimizedVideo from '../ui/OptimizedVideo';
import Skeleton from '../ui/placeholder/Skeleton'; import Skeleton from '../ui/placeholder/Skeleton';
import Transition from '../ui/Transition';
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay'; import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
import StoryCaption from './StoryCaption'; import StoryCaption from './StoryCaption';
import StoryFooter from './StoryFooter'; import StoryFooter from './StoryFooter';
@ -90,9 +94,10 @@ interface StateProps {
areChatSettingsLoaded?: boolean; areChatSettingsLoaded?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
stealthMode: ApiStealthMode; stealthMode: ApiStealthMode;
withHeaderAnimation?: boolean;
} }
const VIDEO_MIN_READY_STATE = 4; const VIDEO_MIN_READY_STATE = IS_SAFARI ? 4 : 3;
const SPACEBAR_CODE = 32; const SPACEBAR_CODE = 32;
const STEALTH_MODE_NOTIFICATION_DURATION = 4000; const STEALTH_MODE_NOTIFICATION_DURATION = 4000;
@ -120,6 +125,7 @@ function Story({
onDelete, onDelete,
onClose, onClose,
onReport, onReport,
withHeaderAnimation,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { const {
viewStory, viewStory,
@ -142,7 +148,6 @@ function Story({
const lang = useLang(); const lang = useLang();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const [, setCurrentTime] = useCurrentTimeSignal();
const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false); const [isComposerHasFocus, markComposerHasFocus, unmarkComposerHasFocus] = useFlag(false);
const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false); const [isStoryPlaybackRequested, playStory, pauseStory] = useFlag(false);
const [isStoryPlaying, markStoryPlaying, unmarkStoryPlaying] = useFlag(false); const [isStoryPlaying, markStoryPlaying, unmarkStoryPlaying] = useFlag(false);
@ -171,7 +176,6 @@ function Story({
} = useStoryProps(story, isCurrentUserPremium, isDropdownMenuOpen); } = useStoryProps(story, isCurrentUserPremium, isDropdownMenuOpen);
const isLoadedStory = story && 'content' in story; const isLoadedStory = story && 'content' in story;
const isChangelog = peerId === storyChangelogUserId; const isChangelog = peerId === storyChangelogUserId;
const isChannel = !isUserId(peerId); const isChannel = !isUserId(peerId);
const isOut = isLoadedStory && story.isOut; const isOut = isLoadedStory && story.isOut;
@ -216,9 +220,11 @@ function Story({
: undefined; : undefined;
const shouldShowFooter = isLoadedStory && (isOut || isChannel); const shouldShowFooter = isLoadedStory && (isOut || isChannel);
const headerAnimation = isMobile && withHeaderAnimation ? 'slideFade' : 'none';
const { const {
shouldRender: shouldRenderSkeleton, transitionClassNames: skeletonTransitionClassNames, shouldRender: shouldRenderSkeleton,
transitionClassNames: skeletonTransitionClassNames,
} = useShowTransition(!hasFullData); } = useShowTransition(!hasFullData);
const { const {
@ -239,8 +245,12 @@ function Story({
} = useShowTransition(hasText && isCaptionExpanded); } = useShowTransition(hasText && isCaptionExpanded);
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true); 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); useStoryPreloader(peerId, storyId);
@ -308,11 +318,18 @@ function Story({
onTouchEnd: handleLongPressTouchEnd, onTouchEnd: handleLongPressTouchEnd,
} = useLongPress(handleLongPressStart, handleLongPressEnd); } = useLongPress(handleLongPressStart, handleLongPressEnd);
const isUnsupported = useUnsupportedMedia(videoRef, undefined, !isVideo || !fullMediaData); const isUnsupported = useUnsupportedMedia(
videoRef,
undefined,
!isVideo || !fullMediaData || isStreamingSupported,
);
const hasAllData = fullMediaData && (!altMediaHash || altMediaData); const hasAllData = fullMediaData && (!altMediaHash || altMediaData);
// Play story after media has been downloaded // Play story after media has been downloaded
useEffect(() => { if (hasAllData && !isUnsupported) handlePlayStory(); }, [hasAllData, isUnsupported]); useEffect(() => {
if (hasAllData && !isUnsupported) handlePlayStory();
}, [hasAllData, isUnsupported]);
useBackgroundMode(unmarkAppFocused, markAppFocused); useBackgroundMode(unmarkAppFocused, markAppFocused);
useEffect(() => { useEffect(() => {
@ -371,7 +388,9 @@ function Story({
if ( if (
!isPausedBySpacebar || isCaptionExpanded || isComposerHasFocus !isPausedBySpacebar || isCaptionExpanded || isComposerHasFocus
|| shouldForcePause || !isAppFocused || isPausedByLongPress || shouldForcePause || !isAppFocused || isPausedByLongPress
) return; ) {
return;
}
if ( if (
prevIsCaptionExpanded !== isCaptionExpanded prevIsCaptionExpanded !== isCaptionExpanded
@ -395,21 +414,21 @@ function Story({
}); });
const handleOpenPrevStory = useLastCallback(() => { const handleOpenPrevStory = useLastCallback(() => {
setCurrentTime(0);
openPreviousStory(); openPreviousStory();
}); });
const handleOpenNextStory = useLastCallback(() => { const handleOpenNextStory = useLastCallback(() => {
setCurrentTime(0);
openNextStory(); openNextStory();
}); });
const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => { const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
const video = e.currentTarget; const video = e.currentTarget;
if (video.readyState >= VIDEO_MIN_READY_STATE) { 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(); handleOpenNextStory();
} }
}); });
@ -434,7 +453,6 @@ function Story({
}); });
const handleDeleteStoryClick = useLastCallback(() => { const handleDeleteStoryClick = useLastCallback(() => {
setCurrentTime(0);
onDelete(story!); onDelete(story!);
}); });
@ -597,15 +615,15 @@ function Story({
); );
} }
function renderSender() { function renderSenderInfo() {
return ( return (
<div className={styles.sender}> <div className={styles.senderInfo}>
<Avatar <Avatar
peer={peer} peer={peer}
size="tiny" size="tiny"
onClick={handleOpenChat} onClick={handleOpenChat}
/> />
<div className={styles.senderInfo}> <div className={styles.senderMeta}>
<span onClick={handleOpenChat} className={styles.senderName}> <span onClick={handleOpenChat} className={styles.senderName}>
{renderText(getSenderTitle(lang, peer) || '')} {renderText(getSenderTitle(lang, peer) || '')}
</span> </span>
@ -631,6 +649,16 @@ function Story({
)} )}
</div> </div>
</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}> <div className={styles.actions}>
{renderStoryPrivacyButton()} {renderStoryPrivacyButton()}
@ -713,10 +741,11 @@ function Story({
<canvas ref={thumbRef} className={styles.thumbnail} /> <canvas ref={thumbRef} className={styles.thumbnail} />
{previewBlobUrl && ( {previewBlobUrl && (
<img <img
key={`preview-${storyId}`}
src={previewBlobUrl} src={previewBlobUrl}
draggable={false} draggable={false}
alt="" alt=""
className={buildClassName(styles.media, previewTransitionClassNames)} className={buildClassName(styles.media, styles.mediaPreview, previewTransitionClassNames)}
/> />
)} )}
{shouldRenderSkeleton && ( {shouldRenderSkeleton && (
@ -733,14 +762,16 @@ function Story({
{isVideo && fullMediaData && ( {isVideo && fullMediaData && (
<OptimizedVideo <OptimizedVideo
ref={videoRef} ref={videoRef}
key={`video-${storyId}`}
className={buildClassName(styles.media, mediaTransitionClassNames)} className={buildClassName(styles.media, mediaTransitionClassNames)}
canPlay={isStoryPlaybackRequested} canPlay={isStoryPlaybackRequested}
muted={isMuted} muted={isMuted}
width={BASE_STORY_WIDTH}
height={BASE_STORY_HEIGHT}
draggable={false} draggable={false}
playsInline playsInline
disablePictureInPicture disablePictureInPicture
isPriority isPriority
onPlaying={markStoryPlaying}
onPause={unmarkStoryPlaying} onPause={unmarkStoryPlaying}
onWaiting={unmarkStoryPlaying} onWaiting={unmarkStoryPlaying}
disableRemotePlayback disableRemotePlayback
@ -784,15 +815,15 @@ function Story({
aria-label={lang('Close')} aria-label={lang('Close')}
/> />
)} )}
{hasText && <div className={styles.captionGradient} />} {hasText && <div className={buildClassName(styles.captionGradient, captionAppearanceAnimationClassNames)} />}
{(hasText || hasForwardInfo) && ( {shouldRenderCaption && (
<StoryCaption <StoryCaption
key={`caption-${storyId}-${peerId}`} key={`caption-${storyId}-${peerId}`}
story={story as ApiStory} story={story as ApiStory}
isExpanded={isCaptionExpanded} isExpanded={isCaptionExpanded}
onExpand={expandCaption} onExpand={expandCaption}
onFold={foldCaption} onFold={foldCaption}
className={appearanceAnimationClassNames} className={captionAppearanceAnimationClassNames}
/> />
)} )}
{shouldRenderComposer && ( {shouldRenderComposer && (
@ -850,6 +881,7 @@ export default memo(withGlobal<OwnProps>((global, {
const forwardSenderId = forwardInfo?.fromPeerId const forwardSenderId = forwardInfo?.fromPeerId
|| mediaAreas?.find((area): area is ApiMediaAreaChannelPost => area.type === 'channelPost')?.channelId; || mediaAreas?.find((area): area is ApiMediaAreaChannelPost => area.type === 'channelPost')?.channelId;
const forwardSender = forwardSenderId ? selectPeer(global, forwardSenderId) : undefined; const forwardSender = forwardSenderId ? selectPeer(global, forwardSenderId) : undefined;
const withHeaderAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations');
return { return {
peer: (user || chat)!, peer: (user || chat)!,
@ -864,5 +896,6 @@ export default memo(withGlobal<OwnProps>((global, {
isChatExist: Boolean(chat), isChatExist: Boolean(chat),
areChatSettingsLoaded: Boolean(chat?.settings), areChatSettingsLoaded: Boolean(chat?.settings),
stealthMode: global.stories.stealthMode, stealthMode: global.stories.stealthMode,
withHeaderAnimation,
}; };
})(Story)); })(Story));

View File

@ -8,10 +8,12 @@ import type { StoryViewerOrigin } from '../../types';
import { getSenderTitle, getStoryMediaHash } from '../../global/helpers'; import { getSenderTitle, getStoryMediaHash } from '../../global/helpers';
import { selectTabState } from '../../global/selectors'; import { selectTabState } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useShowTransition from '../../hooks/useShowTransition';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
import MediaAreaOverlay from './mediaArea/MediaAreaOverlay'; import MediaAreaOverlay from './mediaArea/MediaAreaOverlay';
@ -33,6 +35,7 @@ function StoryPreview({
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { openStoryViewer, loadPeerSkippedStories } = getActions(); const { openStoryViewer, loadPeerSkippedStories } = getActions();
const lang = useLang(); const lang = useLang();
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true);
const story = useMemo<ApiTypeStory | undefined>(() => { const story = useMemo<ApiTypeStory | undefined>(() => {
if (!peerStories) { if (!peerStories) {
@ -76,13 +79,15 @@ function StoryPreview({
)} )}
{isLoaded && <MediaAreaOverlay story={story} />} {isLoaded && <MediaAreaOverlay story={story} />}
<div className={styles.content}> <div className={buildClassName(styles.content, appearanceAnimationClassNames)}>
<Avatar <div className={styles.contentInner}>
peer={peer} <Avatar
withStory peer={peer}
storyViewerMode="disabled" withStory
/> storyViewerMode="disabled"
<div className={styles.name}>{renderText(getSenderTitle(lang, peer) || '')}</div> />
<div className={styles.name}>{renderText(getSenderTitle(lang, peer) || '')}</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -9,7 +9,11 @@ import type { RealTouchEvent } from '../../util/captureEvents';
import { ANIMATION_END_DELAY, EDITABLE_STORY_INPUT_ID } from '../../config'; import { ANIMATION_END_DELAY, EDITABLE_STORY_INPUT_ID } from '../../config';
import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { getStoryKey } from '../../global/helpers'; 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 buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle'; import buildStyle from '../../util/buildStyle';
import { import {
@ -312,7 +316,7 @@ function StorySlides({
offsetY = clamp(dragOffsetY, -limit, limit); offsetY = clamp(dragOffsetY, -limit, limit);
if (offsetY > 0) { if (offsetY > 0) {
requestMutation(() => { 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) { if (event.type === 'wheel' && Math.abs(offsetY) > SWIPE_Y_THRESHOLD * 2) {
@ -323,9 +327,10 @@ function StorySlides({
}, },
onRelease, onRelease,
}); });
}, [isOpen, renderingPeerId, onClose, windowWidth, windowHeight]); }, [isOpen, onClose, windowWidth, windowHeight, isMobile, renderingPeerId]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (isMobile) return;
const transformX = calculateTransformX(); const transformX = calculateTransformX();
Object.entries(rendersRef.current).forEach(([peerId, { current }]) => { Object.entries(rendersRef.current).forEach(([peerId, { current }]) => {
@ -341,7 +346,6 @@ function StorySlides({
} }
const getScale = () => { const getScale = () => {
if (isMobile) return '1';
if (currentPeerId === peerId) { if (currentPeerId === peerId) {
return String(slideSizes.toActiveScale); return String(slideSizes.toActiveScale);
} }
@ -353,11 +357,11 @@ function StorySlides({
let offsetY = 0; let offsetY = 0;
if (peerId === renderingPeerId) { 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); current.classList.add(styles.slideAnimationFromActive);
} }
if (peerId === currentPeerId) { if (peerId === currentPeerId) {
if (!isMobile) offsetY = ACTIVE_SLIDE_VERTICAL_CORRECTION_REM; offsetY = ACTIVE_SLIDE_VERTICAL_CORRECTION_REM;
current.classList.add(styles.slideAnimationToActive); current.classList.add(styles.slideAnimationToActive);
} }
@ -366,7 +370,33 @@ function StorySlides({
current.style.setProperty('--slide-translate-y', `${offsetY}rem`); current.style.setProperty('--slide-translate-y', `${offsetY}rem`);
current.style.setProperty('--slide-translate-scale', getScale()); 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) { function renderStoryPreview(peerId: string, index: number, position: number) {
const style = buildStyle( const style = buildStyle(
@ -395,7 +425,7 @@ function StorySlides({
} }
function renderStory(peerId: string) { function renderStory(peerId: string) {
const style = buildStyle( const style = isMobile ? undefined : buildStyle(
`width: ${slideSizes.activeSlide.width}px`, `width: ${slideSizes.activeSlide.width}px`,
`--slide-media-height: ${slideSizes.activeSlide.height}px`, `--slide-media-height: ${slideSizes.activeSlide.height}px`,
); );

View File

@ -113,12 +113,20 @@
} }
.slideAnimationFromActive { .slideAnimationFromActive {
.storyHeader,
.composer, .composer,
.caption, .caption,
.storyIndicators { .captionGradient,
.captionBackdrop{
transition: opacity 250ms ease-in-out; transition: opacity 250ms ease-in-out;
opacity: 0; opacity: 0;
} }
.media:not(.mediaPreview) {
opacity: 0 !important;
}
.mediaPreview {
opacity: 1;
}
} }
.slide { .slide {
@ -147,29 +155,18 @@
--slide-x: calc(#{$offset} * var(--story-viewer-scale)); --slide-x: calc(#{$offset} * var(--story-viewer-scale));
} }
} }
}
@media (max-width: 600px) { .mobileSlide {
display: none; width: 100%;
border-radius: 0; height: 100%;
} transform: translateY(var(--slide-translate-y, 0px));
} }
.slidePreview { .slidePreview {
overflow: hidden; overflow: hidden;
transition: opacity 200ms ease-in-out; 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 { &.slideAnimationToActive::before {
transition: opacity 350ms ease-in-out !important; transition: opacity 350ms ease-in-out !important;
opacity: 0; opacity: 0;
@ -185,19 +182,6 @@
height: calc(var(--slide-media-height) + 3.5rem); height: calc(var(--slide-media-height) + 3.5rem);
z-index: 1; 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 { &::before {
pointer-events: none; pointer-events: none;
content: ""; content: "";
@ -210,13 +194,6 @@
opacity: 0; opacity: 0;
z-index: 3; z-index: 3;
} }
@media (min-width: 600.001px) {
&.slideAnimationFromActive::before {
transition: opacity 350ms ease-in-out !important;
opacity: 1;
}
}
} }
.slideInner { .slideInner {
@ -276,6 +253,17 @@
} }
.content { .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; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -371,8 +359,17 @@
align-items: center; align-items: center;
} }
.senderInfoTransition {
position: absolute;
}
.senderInfo { .senderInfo {
display: inline-flex; display: flex;
align-items: center;
}
.senderMeta {
display: flex;
flex-direction: column; flex-direction: column;
margin-left: 0.75rem; margin-left: 0.75rem;
line-height: 1.25rem; line-height: 1.25rem;
@ -605,7 +602,7 @@
left: 0; left: 0;
margin-bottom: 0; margin-bottom: 0;
z-index: 3; z-index: 3;
transition: transform var(--layer-transition); transition: transform var(--layer-transition), opacity 0.15s ease;
&:global(.Composer) { &:global(.Composer) {
--base-height: 3rem; --base-height: 3rem;

View File

@ -2,6 +2,8 @@ import type { IDimensions } from '../../../global/types';
import { roundToNearestEven } from '../../../util/math'; 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_WIDTH = 1200;
const BASE_SCREEN_HEIGHT = 800; const BASE_SCREEN_HEIGHT = 800;
const BASE_ACTIVE_SLIDE_WIDTH = 405; const BASE_ACTIVE_SLIDE_WIDTH = 405;

View File

@ -96,7 +96,7 @@ export function animateClosing(
const { mediaEl: toImage } = getNodes(origin, userId); const { mediaEl: toImage } = getNodes(origin, userId);
const fromImage = document.getElementById('StoryViewer')!.querySelector<HTMLImageElement>( const fromImage = document.getElementById('StoryViewer')!.querySelector<HTMLImageElement>(
`.${styles.activeSlide} .${styles.media}`, `.${styles.mobileSlide} .${styles.media}, .${styles.activeSlide} .${styles.media}`,
); );
if (!fromImage || !toImage) { if (!fromImage || !toImage) {
return; return;

View File

@ -5,6 +5,7 @@ import { ApiMediaFormat } from '../../../api/types';
import { getStoryMediaHash } from '../../../global/helpers'; import { getStoryMediaHash } from '../../../global/helpers';
import { selectPeerStories } from '../../../global/selectors'; import { selectPeerStories } from '../../../global/selectors';
import { preloadImage } from '../../../util/files';
import * as mediaLoader from '../../../util/mediaLoader'; import * as mediaLoader from '../../../util/mediaLoader';
import { getProgressiveUrl } from '../../../util/mediaLoader'; import { getProgressiveUrl } from '../../../util/mediaLoader';
import { makeProgressiveLoader } from '../../../util/progressieveLoader'; import { makeProgressiveLoader } from '../../../util/progressieveLoader';
@ -44,6 +45,9 @@ function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) {
if (format === ApiMediaFormat.Progressive) { if (format === ApiMediaFormat.Progressive) {
preloadProgressive(result); 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[] { function findIdsAroundCurrentId<T>(ids: T[], currentId: T, aroundAmount: number): T[] {
const currentIndex = ids.indexOf(currentId); const currentIndex = ids.indexOf(currentId);
const start = Math.max(currentIndex - aroundAmount, 0);
return ids.slice(currentIndex - aroundAmount, currentIndex + aroundAmount); const end = Math.min(currentIndex + aroundAmount, ids.length);
return ids.slice(start, end);
} }
function getPreloadMediaHashes(peerId: string, storyId: number) { function getPreloadMediaHashes(peerId: string, storyId: number) {
@ -83,11 +88,13 @@ function getPreloadMediaHashes(peerId: string, storyId: number) {
return; return;
} }
const isVideo = Boolean(story.content.video);
// Media // Media
mediaHashes.push({ mediaHashes.push({
hash: getStoryMediaHash(story, 'full'), hash: getStoryMediaHash(story, 'full'),
format: story.content.video ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl, format: isVideo ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl,
isStream: checkIfStreamingSupported(PRIMARY_VIDEO_MIME), isStream: isVideo && checkIfStreamingSupported(PRIMARY_VIDEO_MIME),
}); });
// Thumbnail // Thumbnail
mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl }); mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl });

View File

@ -76,26 +76,26 @@ export function useStreaming(videoRef: RefObject<HTMLVideoElement>, url?: string
}); });
return () => { return () => {
mediaSource.removeEventListener('sourceopen', onSourceOpen);
if (mediaSource.readyState === 'open') {
endOfStream(mediaSource);
}
URL.revokeObjectURL(video.src);
requestMutation(() => { requestMutation(() => {
const src = video.src;
video.pause();
video.src = ''; video.src = '';
applyStyles(video, { video.load();
display: 'none', mediaSource.removeEventListener('sourceopen', onSourceOpen);
opacity: '0', if (mediaSource.readyState === 'open') {
}); endOfStream(mediaSource);
}
URL.revokeObjectURL(src);
}); });
}; };
}, [mimeType, url, videoRef]); }, [mimeType, url, videoRef]);
return checkIfStreamingSupported(mimeType);
} }
export function checkIfStreamingSupported(mimeType: string) { export function checkIfStreamingSupported(mimeType?: string) {
if (!IS_SAFARI) return false; if (!IS_SAFARI || !mimeType) return false;
const MS = getMediaSource(); return Boolean(getMediaSource()?.isTypeSupported(mimeType));
return Boolean(MS && MS.isTypeSupported(mimeType));
} }
function appendBuffer(sourceBuffer: SourceBuffer, buffer: ArrayBuffer) { function appendBuffer(sourceBuffer: SourceBuffer, buffer: ArrayBuffer) {

View File

@ -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( export async function* makeProgressiveLoader(
url: string, url: string,
chunkSize = DEFAULT_CHUNK_SIZE, start = 0,
chunkSize = DEFAULT_PART_SIZE,
): AsyncGenerator<ArrayBuffer, void, undefined> { ): AsyncGenerator<ArrayBuffer, void, undefined> {
let start = 0; const match = url.match(/fileSize=(\d+)/);
let fileSize: number | undefined; let fileSize;
if (match) {
fileSize = match && Number(match[1]);
} else {
fileSize = sizeCache.get(url);
}
while (true) { while (true) {
if (fileSize && start >= fileSize) return;
let end = start + chunkSize - 1; let end = start + chunkSize - 1;
if (fileSize && end > fileSize) { if (fileSize && end > fileSize) {
end = fileSize - 1; end = fileSize - 1;
} }
const res = await fetch(url, { // Check if we have the chunk in memory
headers: { Range: `bytes=${start}-${end}` }, 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 pendingRequests.set(cacheKey, request);
if (!fileSize) { }
const contentRange = res.headers.get('Content-Range');
const match = contentRange?.match(/\/(\d+)$/);
fileSize = match ? Number(match[1]) : undefined;
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 // Yield the chunk data
const chunk = await res.arrayBuffer(); yield arrayBuffer;
yield chunk;
start = end + 1; start = end + 1;
if (start >= fileSize) {
return;
}
} }
} }