Use MediaSource API in Safari for stories (#4100)
This commit is contained in:
parent
a75fc9be51
commit
79ff2c4d06
@ -25,6 +25,7 @@ import { formatMediaDuration, formatRelativeTime } from '../../util/dateFormat';
|
|||||||
import download from '../../util/download';
|
import download from '../../util/download';
|
||||||
import { getServerTime } from '../../util/serverTime';
|
import { getServerTime } from '../../util/serverTime';
|
||||||
import renderText from '../common/helpers/renderText';
|
import renderText from '../common/helpers/renderText';
|
||||||
|
import { PRIMARY_VIDEO_MIME, SECONDARY_VIDEO_MIME } from './helpers/videoFormats';
|
||||||
|
|
||||||
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
import useUnsupportedMedia from '../../hooks/media/useUnsupportedMedia';
|
||||||
import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
|
import useAppLayout, { getIsMobile } from '../../hooks/useAppLayout';
|
||||||
@ -39,6 +40,7 @@ import useLastCallback from '../../hooks/useLastCallback';
|
|||||||
import useLongPress from '../../hooks/useLongPress';
|
import useLongPress from '../../hooks/useLongPress';
|
||||||
import useMediaTransition from '../../hooks/useMediaTransition';
|
import useMediaTransition from '../../hooks/useMediaTransition';
|
||||||
import useShowTransition from '../../hooks/useShowTransition';
|
import useShowTransition from '../../hooks/useShowTransition';
|
||||||
|
import { useStreaming } from '../../hooks/useStreaming';
|
||||||
import useStoryPreloader from './hooks/useStoryPreloader';
|
import useStoryPreloader from './hooks/useStoryPreloader';
|
||||||
import useStoryProps from './hooks/useStoryProps';
|
import useStoryProps from './hooks/useStoryProps';
|
||||||
|
|
||||||
@ -92,9 +94,6 @@ interface StateProps {
|
|||||||
const VIDEO_MIN_READY_STATE = 4;
|
const VIDEO_MIN_READY_STATE = 4;
|
||||||
const SPACEBAR_CODE = 32;
|
const SPACEBAR_CODE = 32;
|
||||||
|
|
||||||
const PRIMARY_VIDEO_MIME = 'video/mp4; codecs=hvc1.1.6.L63.00';
|
|
||||||
const SECONDARY_VIDEO_MIME = 'video/mp4; codecs=avc1.64001E';
|
|
||||||
|
|
||||||
const STEALTH_MODE_NOTIFICATION_DURATION = 4000;
|
const STEALTH_MODE_NOTIFICATION_DURATION = 4000;
|
||||||
|
|
||||||
function Story({
|
function Story({
|
||||||
@ -211,6 +210,10 @@ function Story({
|
|||||||
&& !isPausedBySpacebar && !isPausedByLongPress,
|
&& !isPausedBySpacebar && !isPausedByLongPress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const duration = isLoadedStory && story.content.video?.duration
|
||||||
|
? story.content.video.duration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const shouldShowFooter = isLoadedStory && (isOut || isChannel);
|
const shouldShowFooter = isLoadedStory && (isOut || isChannel);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -236,6 +239,8 @@ function Story({
|
|||||||
|
|
||||||
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true);
|
const { transitionClassNames: appearanceAnimationClassNames } = useShowTransition(true);
|
||||||
|
|
||||||
|
useStreaming(videoRef, fullMediaData, PRIMARY_VIDEO_MIME);
|
||||||
|
|
||||||
useStoryPreloader(peerId, storyId);
|
useStoryPreloader(peerId, storyId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -378,13 +383,6 @@ function Story({
|
|||||||
}
|
}
|
||||||
}, [isComposerHasFocus, isCaptionExpanded, shouldForcePause, isAppFocused, isPausedByLongPress, isPausedBySpacebar]);
|
}, [isComposerHasFocus, isCaptionExpanded, shouldForcePause, isAppFocused, isPausedByLongPress, isPausedBySpacebar]);
|
||||||
|
|
||||||
const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
const video = e.currentTarget;
|
|
||||||
if (video.readyState >= VIDEO_MIN_READY_STATE) {
|
|
||||||
setCurrentTime(video.currentTime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenChat = useLastCallback(() => {
|
const handleOpenChat = useLastCallback(() => {
|
||||||
onClose();
|
onClose();
|
||||||
openChat({ id: peerId });
|
openChat({ id: peerId });
|
||||||
@ -405,6 +403,16 @@ function Story({
|
|||||||
openNextStory();
|
openNextStory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleVideoStoryTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = e.currentTarget;
|
||||||
|
if (video.readyState >= VIDEO_MIN_READY_STATE) {
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
}
|
||||||
|
if (duration && video.currentTime >= duration) {
|
||||||
|
handleOpenNextStory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return !getIsAnimating() && !isComposerHasFocus ? captureKeyboardListeners({
|
return !getIsAnimating() && !isComposerHasFocus ? captureKeyboardListeners({
|
||||||
onRight: handleOpenNextStory,
|
onRight: handleOpenNextStory,
|
||||||
@ -522,10 +530,6 @@ function Story({
|
|||||||
}, [isMobile, lang]);
|
}, [isMobile, lang]);
|
||||||
|
|
||||||
function renderStoriesTabs() {
|
function renderStoriesTabs() {
|
||||||
const duration = isLoadedStory && story.content.video?.duration
|
|
||||||
? story.content.video.duration
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.storyIndicators}>
|
<div className={styles.storyIndicators}>
|
||||||
{(isSingleStory ? [storyId] : orderedIds ?? []).map((id) => (
|
{(isSingleStory ? [storyId] : orderedIds ?? []).map((id) => (
|
||||||
@ -738,8 +742,8 @@ function Story({
|
|||||||
onPlaying={markStoryPlaying}
|
onPlaying={markStoryPlaying}
|
||||||
onPause={unmarkStoryPlaying}
|
onPause={unmarkStoryPlaying}
|
||||||
onWaiting={unmarkStoryPlaying}
|
onWaiting={unmarkStoryPlaying}
|
||||||
|
disableRemotePlayback
|
||||||
onTimeUpdate={handleVideoStoryTimeUpdate}
|
onTimeUpdate={handleVideoStoryTimeUpdate}
|
||||||
onEnded={handleOpenNextStory}
|
|
||||||
>
|
>
|
||||||
<source src={fullMediaData} type={PRIMARY_VIDEO_MIME} width="720" />
|
<source src={fullMediaData} type={PRIMARY_VIDEO_MIME} width="720" />
|
||||||
{altMediaData && <source src={altMediaData} type={SECONDARY_VIDEO_MIME} width="480" />}
|
{altMediaData && <source src={altMediaData} type={SECONDARY_VIDEO_MIME} width="480" />}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ function StoryToggler({
|
|||||||
const preloadPeerIds = useMemo(() => {
|
const preloadPeerIds = useMemo(() => {
|
||||||
return orderedPeerIds.slice(0, PRELOAD_PEERS);
|
return orderedPeerIds.slice(0, PRELOAD_PEERS);
|
||||||
}, [orderedPeerIds]);
|
}, [orderedPeerIds]);
|
||||||
|
|
||||||
useStoryPreloader(preloadPeerIds);
|
useStoryPreloader(preloadPeerIds);
|
||||||
|
|
||||||
const isVisible = canShow && isShown;
|
const isVisible = canShow && isShown;
|
||||||
|
|||||||
@ -248,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(body.ghost-animating) & {
|
:global(body.ghost-animating) & {
|
||||||
display: none;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +262,8 @@
|
|||||||
height: inherit;
|
height: inherit;
|
||||||
border-radius: var(--border-radius-default-small);
|
border-radius: var(--border-radius-default-small);
|
||||||
|
|
||||||
|
transition: opacity 300ms;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -269,7 +271,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(body.ghost-animating) .activeSlide & {
|
:global(body.ghost-animating) .activeSlide & {
|
||||||
display: none;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
src/components/story/helpers/videoFormats.ts
Normal file
2
src/components/story/helpers/videoFormats.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const PRIMARY_VIDEO_MIME = 'video/mp4; codecs=hvc1.1.6.L63.00';
|
||||||
|
export const SECONDARY_VIDEO_MIME = 'video/mp4; codecs=avc1.64001E';
|
||||||
@ -6,29 +6,45 @@ 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 * as mediaLoader from '../../../util/mediaLoader';
|
import * as mediaLoader from '../../../util/mediaLoader';
|
||||||
|
import { getProgressiveUrl } from '../../../util/mediaLoader';
|
||||||
|
import { makeProgressiveLoader } from '../../../util/progressieveLoader';
|
||||||
import { pause } from '../../../util/schedulers';
|
import { pause } from '../../../util/schedulers';
|
||||||
|
import { PRIMARY_VIDEO_MIME } from '../helpers/videoFormats';
|
||||||
|
|
||||||
|
import { checkIfStreamingSupported } from '../../../hooks/useStreaming';
|
||||||
|
|
||||||
const preloadedStories: Record<string, Set<number>> = {};
|
const preloadedStories: Record<string, Set<number>> = {};
|
||||||
const PEER_STORIES_FOR_PRELOAD = 5;
|
const PEER_STORIES_FOR_PRELOAD = 5;
|
||||||
const PROGRESSIVE_PRELOAD_DURATION = 1000;
|
const PROGRESSIVE_PRELOAD_DURATION = 1000;
|
||||||
|
const STREAM_PRELOAD_SIZE = 1024 * 1024 * 2; // 2 MB
|
||||||
|
|
||||||
const FIRST_PRELOAD_DELAY = 1000;
|
const FIRST_PRELOAD_DELAY = 1000;
|
||||||
const canPreload = pause(FIRST_PRELOAD_DELAY);
|
const canPreload = pause(FIRST_PRELOAD_DELAY);
|
||||||
|
|
||||||
|
type MediaHash = { hash: string; format: ApiMediaFormat; isStream?: boolean };
|
||||||
|
|
||||||
function useStoryPreloader(peerIds: string[]): void;
|
function useStoryPreloader(peerIds: string[]): void;
|
||||||
function useStoryPreloader(peerId?: string, aroundStoryId?: number): void;
|
function useStoryPreloader(peerId?: string, aroundStoryId?: number): void;
|
||||||
function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) {
|
function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (peerId === undefined) return;
|
if (peerId === undefined) return;
|
||||||
|
|
||||||
const preloadHashes = async (mediaHashes: { hash: string; format: ApiMediaFormat }[]) => {
|
const preloadHashes = async (mediaHashes: Array<MediaHash>) => {
|
||||||
await canPreload;
|
await canPreload;
|
||||||
mediaHashes.forEach(({ hash, format }) => {
|
mediaHashes.forEach(({ hash, format, isStream }) => {
|
||||||
mediaLoader.fetch(hash, format).then((result) => {
|
if (isStream) {
|
||||||
if (format === ApiMediaFormat.Progressive) {
|
preloadStream(hash);
|
||||||
preloadProgressive(result);
|
return;
|
||||||
}
|
}
|
||||||
});
|
mediaLoader.fetch(
|
||||||
|
hash,
|
||||||
|
format,
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
if (format === ApiMediaFormat.Progressive) {
|
||||||
|
preloadProgressive(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,7 +72,7 @@ function getPreloadMediaHashes(peerId: string, storyId: number) {
|
|||||||
|
|
||||||
const preloadIds = findIdsAroundCurrentId(peerStories.orderedIds, storyId, PEER_STORIES_FOR_PRELOAD);
|
const preloadIds = findIdsAroundCurrentId(peerStories.orderedIds, storyId, PEER_STORIES_FOR_PRELOAD);
|
||||||
|
|
||||||
const mediaHashes: { hash: string; format: ApiMediaFormat }[] = [];
|
const mediaHashes: Array<MediaHash> = [];
|
||||||
preloadIds.forEach((currentStoryId) => {
|
preloadIds.forEach((currentStoryId) => {
|
||||||
if (preloadedStories[peerId]?.has(currentStoryId)) {
|
if (preloadedStories[peerId]?.has(currentStoryId)) {
|
||||||
return;
|
return;
|
||||||
@ -71,12 +87,15 @@ function getPreloadMediaHashes(peerId: string, storyId: number) {
|
|||||||
mediaHashes.push({
|
mediaHashes.push({
|
||||||
hash: getStoryMediaHash(story, 'full'),
|
hash: getStoryMediaHash(story, 'full'),
|
||||||
format: story.content.video ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl,
|
format: story.content.video ? ApiMediaFormat.Progressive : ApiMediaFormat.BlobUrl,
|
||||||
|
isStream: checkIfStreamingSupported(PRIMARY_VIDEO_MIME),
|
||||||
});
|
});
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl });
|
mediaHashes.push({ hash: getStoryMediaHash(story), format: ApiMediaFormat.BlobUrl });
|
||||||
// Alt video with different codec
|
|
||||||
if (story.content.altVideo) {
|
if (story.content.altVideo) {
|
||||||
mediaHashes.push({ hash: getStoryMediaHash(story, 'full', true)!, format: ApiMediaFormat.Progressive });
|
mediaHashes.push({
|
||||||
|
hash: getStoryMediaHash(story, 'full', true)!,
|
||||||
|
format: ApiMediaFormat.Progressive,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadedStories[peerId] = (preloadedStories[peerId] || new Set()).add(currentStoryId);
|
preloadedStories[peerId] = (preloadedStories[peerId] || new Set()).add(currentStoryId);
|
||||||
@ -92,12 +111,27 @@ function preloadProgressive(url: string) {
|
|||||||
video.src = url;
|
video.src = url;
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.autoplay = true;
|
video.autoplay = true;
|
||||||
|
video.disableRemotePlayback = true;
|
||||||
video.style.display = 'none';
|
video.style.display = 'none';
|
||||||
head.appendChild(video);
|
head.appendChild(video);
|
||||||
|
video.load();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
video.pause();
|
||||||
|
video.src = '';
|
||||||
|
video.load();
|
||||||
head.removeChild(video);
|
head.removeChild(video);
|
||||||
}, PROGRESSIVE_PRELOAD_DURATION);
|
}, PROGRESSIVE_PRELOAD_DURATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function preloadStream(hash: string) {
|
||||||
|
const loader = makeProgressiveLoader(getProgressiveUrl(hash));
|
||||||
|
let cachedSize = 0;
|
||||||
|
for await (const chunk of loader) {
|
||||||
|
cachedSize += chunk.byteLength;
|
||||||
|
if (cachedSize >= STREAM_PRELOAD_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default useStoryPreloader;
|
export default useStoryPreloader;
|
||||||
|
|||||||
132
src/hooks/useStreaming.ts
Normal file
132
src/hooks/useStreaming.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { useEffect } from '../lib/teact/teact';
|
||||||
|
|
||||||
|
import { DEBUG } from '../config';
|
||||||
|
import { requestMutation } from '../lib/fasterdom/fasterdom';
|
||||||
|
import { applyStyles } from '../util/animation';
|
||||||
|
import { makeProgressiveLoader } from '../util/progressieveLoader';
|
||||||
|
import { IS_SAFARI } from '../util/windowEnvironment';
|
||||||
|
|
||||||
|
const VIDEO_REVEAL_DELAY = 100;
|
||||||
|
|
||||||
|
export function useStreaming(videoRef: RefObject<HTMLVideoElement>, url?: string, mimeType?: string) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || !videoRef.current) return undefined;
|
||||||
|
const MediaSourceClass = getMediaSource();
|
||||||
|
const video = videoRef.current;
|
||||||
|
|
||||||
|
if (!IS_SAFARI || !mimeType || !MediaSourceClass?.isTypeSupported(mimeType)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mediaSource = new MediaSourceClass();
|
||||||
|
|
||||||
|
function revealVideo() {
|
||||||
|
requestMutation(() => {
|
||||||
|
video.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
requestMutation(() => {
|
||||||
|
applyStyles(video, { opacity: '1' });
|
||||||
|
});
|
||||||
|
}, VIDEO_REVEAL_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSourceOpen() {
|
||||||
|
if (!url || !mimeType) return;
|
||||||
|
|
||||||
|
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||||
|
const loader = makeProgressiveLoader(url);
|
||||||
|
|
||||||
|
function onUpdateEnded() {
|
||||||
|
loader.next()
|
||||||
|
.then(({
|
||||||
|
value,
|
||||||
|
done,
|
||||||
|
}) => {
|
||||||
|
if (mediaSource.readyState !== 'open') return;
|
||||||
|
if (done) {
|
||||||
|
endOfStream(mediaSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendBuffer(sourceBuffer, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBuffer.addEventListener('updateend', onUpdateEnded);
|
||||||
|
|
||||||
|
loader.next()
|
||||||
|
.then(({
|
||||||
|
value,
|
||||||
|
done,
|
||||||
|
}) => {
|
||||||
|
if (done || mediaSource.readyState !== 'open') return;
|
||||||
|
revealVideo();
|
||||||
|
appendBuffer(sourceBuffer, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSource.addEventListener('sourceopen', onSourceOpen, { once: true });
|
||||||
|
|
||||||
|
requestMutation(() => {
|
||||||
|
applyStyles(video, {
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
});
|
||||||
|
video.src = URL.createObjectURL(mediaSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaSource.removeEventListener('sourceopen', onSourceOpen);
|
||||||
|
if (mediaSource.readyState === 'open') {
|
||||||
|
endOfStream(mediaSource);
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
requestMutation(() => {
|
||||||
|
video.src = '';
|
||||||
|
applyStyles(video, {
|
||||||
|
display: 'none',
|
||||||
|
opacity: '0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [mimeType, url, videoRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkIfStreamingSupported(mimeType: string) {
|
||||||
|
if (!IS_SAFARI) return false;
|
||||||
|
const MS = getMediaSource();
|
||||||
|
return Boolean(MS && MS.isTypeSupported(mimeType));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBuffer(sourceBuffer: SourceBuffer, buffer: ArrayBuffer) {
|
||||||
|
try {
|
||||||
|
sourceBuffer.appendBuffer(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[Stream] failed to append buffer', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfStream(mediaSource: MediaSource) {
|
||||||
|
try {
|
||||||
|
mediaSource.endOfStream();
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[Stream] failed to end stream', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaSource(): typeof MediaSource | undefined {
|
||||||
|
if ('ManagedMediaSource' in window) {
|
||||||
|
// @ts-ignore
|
||||||
|
return ManagedMediaSource;
|
||||||
|
}
|
||||||
|
if ('MediaSource' in window) {
|
||||||
|
return MediaSource;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -115,6 +115,10 @@ export function removeCallback(url: string, callbackUniqueId: string) {
|
|||||||
callbacks.delete(callbackUniqueId);
|
callbacks.delete(callbackUniqueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProgressiveUrl(url: string) {
|
||||||
|
return `${PROGRESSIVE_URL_PREFIX}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getProgressive(url: string) {
|
function getProgressive(url: string) {
|
||||||
const progressiveUrl = `${PROGRESSIVE_URL_PREFIX}${url}`;
|
const progressiveUrl = `${PROGRESSIVE_URL_PREFIX}${url}`;
|
||||||
|
|
||||||
|
|||||||
40
src/util/progressieveLoader.ts
Normal file
40
src/util/progressieveLoader.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const DEFAULT_CHUNK_SIZE = 256 * 1024;
|
||||||
|
export async function* makeProgressiveLoader(
|
||||||
|
url: string,
|
||||||
|
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||||
|
): AsyncGenerator<ArrayBuffer, void, undefined> {
|
||||||
|
let start = 0;
|
||||||
|
let fileSize: number | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let end = start + chunkSize - 1;
|
||||||
|
if (fileSize && end > fileSize) {
|
||||||
|
end = fileSize - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Range: `bytes=${start}-${end}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (!fileSize) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield the chunk data
|
||||||
|
const chunk = await res.arrayBuffer();
|
||||||
|
yield chunk;
|
||||||
|
|
||||||
|
start = end + 1;
|
||||||
|
|
||||||
|
if (start >= fileSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user