From 7ab76098041ee26e89c9aade59e5310af11c81b0 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 12 Jan 2024 12:59:59 +0100 Subject: [PATCH] Stories: Improve mobile design, animation and preloading --- src/components/story/Story.tsx | 77 +++++++++++++------ src/components/story/StoryPreview.tsx | 19 +++-- src/components/story/StorySlides.tsx | 46 +++++++++-- src/components/story/StoryViewer.module.scss | 75 +++++++++--------- src/components/story/helpers/dimensions.ts | 2 + .../story/helpers/ghostAnimation.ts | 2 +- .../story/hooks/useStoryPreloader.ts | 15 +++- src/hooks/useStreaming.ts | 26 +++---- src/util/progressieveLoader.ts | 77 ++++++++++++++----- 9 files changed, 225 insertions(+), 114 deletions(-) diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index fde59fa50..444cb6b2b 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -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) => { 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 ( -
+
-
+
{renderText(getSenderTitle(lang, peer) || '')} @@ -631,6 +649,16 @@ function Story({ )}
+
+ ); + } + + function renderSender() { + return ( +
+ + {renderSenderInfo()} +
{renderStoryPrivacyButton()} @@ -713,10 +741,11 @@ function Story({ {previewBlobUrl && ( )} {shouldRenderSkeleton && ( @@ -733,14 +762,16 @@ function Story({ {isVideo && fullMediaData && ( )} - {hasText &&
} - {(hasText || hasForwardInfo) && ( + {hasText &&
} + {shouldRenderCaption && ( )} {shouldRenderComposer && ( @@ -850,6 +881,7 @@ export default memo(withGlobal((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((global, { isChatExist: Boolean(chat), areChatSettingsLoaded: Boolean(chat?.settings), stealthMode: global.stories.stealthMode, + withHeaderAnimation, }; })(Story)); diff --git a/src/components/story/StoryPreview.tsx b/src/components/story/StoryPreview.tsx index 09b37710b..7389fc7cf 100644 --- a/src/components/story/StoryPreview.tsx +++ b/src/components/story/StoryPreview.tsx @@ -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(() => { if (!peerStories) { @@ -76,13 +79,15 @@ function StoryPreview({ )} {isLoaded && } -
- -
{renderText(getSenderTitle(lang, peer) || '')}
+
+
+ +
{renderText(getSenderTitle(lang, peer) || '')}
+
); diff --git a/src/components/story/StorySlides.tsx b/src/components/story/StorySlides.tsx index e4caadb9f..91559df19 100644 --- a/src/components/story/StorySlides.tsx +++ b/src/components/story/StorySlides.tsx @@ -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 ( +
+
setRef(ref, renderingPeerId!)} + > + +
+
+ ); + } 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`, ); diff --git a/src/components/story/StoryViewer.module.scss b/src/components/story/StoryViewer.module.scss index 82774579b..5a637726f 100644 --- a/src/components/story/StoryViewer.module.scss +++ b/src/components/story/StoryViewer.module.scss @@ -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; diff --git a/src/components/story/helpers/dimensions.ts b/src/components/story/helpers/dimensions.ts index 3aa34e955..ec7ab7808 100644 --- a/src/components/story/helpers/dimensions.ts +++ b/src/components/story/helpers/dimensions.ts @@ -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; diff --git a/src/components/story/helpers/ghostAnimation.ts b/src/components/story/helpers/ghostAnimation.ts index 2b7dd547a..24eae90f5 100644 --- a/src/components/story/helpers/ghostAnimation.ts +++ b/src/components/story/helpers/ghostAnimation.ts @@ -96,7 +96,7 @@ export function animateClosing( const { mediaEl: toImage } = getNodes(origin, userId); const fromImage = document.getElementById('StoryViewer')!.querySelector( - `.${styles.activeSlide} .${styles.media}`, + `.${styles.mobileSlide} .${styles.media}, .${styles.activeSlide} .${styles.media}`, ); if (!fromImage || !toImage) { return; diff --git a/src/components/story/hooks/useStoryPreloader.ts b/src/components/story/hooks/useStoryPreloader.ts index 7c1920656..be01a1d9c 100644 --- a/src/components/story/hooks/useStoryPreloader.ts +++ b/src/components/story/hooks/useStoryPreloader.ts @@ -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(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 }); diff --git a/src/hooks/useStreaming.ts b/src/hooks/useStreaming.ts index a9a222eee..d00e6b785 100644 --- a/src/hooks/useStreaming.ts +++ b/src/hooks/useStreaming.ts @@ -76,26 +76,26 @@ export function useStreaming(videoRef: RefObject, 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) { diff --git a/src/util/progressieveLoader.ts b/src/util/progressieveLoader.ts index 2f5018296..c43ead508 100644 --- a/src/util/progressieveLoader.ts +++ b/src/util/progressieveLoader.ts @@ -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(); +const sizeCache = new Map(); +const pendingRequests = new Map>(); + export async function* makeProgressiveLoader( url: string, - chunkSize = DEFAULT_CHUNK_SIZE, + start = 0, + chunkSize = DEFAULT_PART_SIZE, ): AsyncGenerator { - 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; - } } }