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,
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));

View File

@ -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>
);

View File

@ -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`,
);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 });

View File

@ -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) {

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(
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;
}
}
}