Stories: Improve mobile design, animation and preloading
This commit is contained in:
parent
1f029966ae
commit
7ab7609804
@ -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));
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user