721 lines
21 KiB
TypeScript
721 lines
21 KiB
TypeScript
import type { FC } from '../../lib/teact/teact';
|
|
import React, {
|
|
memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
|
} from '../../lib/teact/teact';
|
|
import { getActions } from '../../global';
|
|
|
|
import type {
|
|
ApiAudio, ApiMessage, ApiVideo, ApiVoice,
|
|
} from '../../api/types';
|
|
import type { BufferedRange } from '../../hooks/useBuffering';
|
|
import type { OldLangFn } from '../../hooks/useOldLang';
|
|
import type { ISettings } from '../../types';
|
|
import { ApiMediaFormat } from '../../api/types';
|
|
import { AudioOrigin } from '../../types';
|
|
|
|
import {
|
|
getMediaDuration,
|
|
getMediaFormat,
|
|
getMediaHash,
|
|
getMediaTransferState,
|
|
getMessageWebPageAudio,
|
|
hasMessageTtl,
|
|
isMessageLocal,
|
|
isOwnMessage,
|
|
} from '../../global/helpers';
|
|
import { makeTrackId } from '../../util/audioPlayer';
|
|
import buildClassName from '../../util/buildClassName';
|
|
import { captureEvents } from '../../util/captureEvents';
|
|
import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '../../util/dates/dateFormat';
|
|
import { decodeWaveform, interpolateArray } from '../../util/waveform';
|
|
import { LOCAL_TGS_URLS } from './helpers/animatedAssets';
|
|
import { getFileSizeString } from './helpers/documentInfo';
|
|
import renderText from './helpers/renderText';
|
|
import { MAX_EMPTY_WAVEFORM_POINTS, renderWaveform } from './helpers/waveform';
|
|
|
|
import useAppLayout from '../../hooks/useAppLayout';
|
|
import useAudioPlayer from '../../hooks/useAudioPlayer';
|
|
import useBuffering from '../../hooks/useBuffering';
|
|
import useLastCallback from '../../hooks/useLastCallback';
|
|
import useMedia from '../../hooks/useMedia';
|
|
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
|
|
import useOldLang from '../../hooks/useOldLang';
|
|
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
|
|
|
|
import Button from '../ui/Button';
|
|
import Link from '../ui/Link';
|
|
import ProgressSpinner from '../ui/ProgressSpinner';
|
|
import AnimatedIcon from './AnimatedIcon';
|
|
import Icon from './icons/Icon';
|
|
|
|
import './Audio.scss';
|
|
|
|
type OwnProps = {
|
|
theme: ISettings['theme'];
|
|
message: ApiMessage;
|
|
senderTitle?: string;
|
|
uploadProgress?: number;
|
|
origin: AudioOrigin;
|
|
date?: number;
|
|
noAvatars?: boolean;
|
|
className?: string;
|
|
isSelectable?: boolean;
|
|
isSelected?: boolean;
|
|
isDownloading?: boolean;
|
|
isTranscribing?: boolean;
|
|
isTranscribed?: boolean;
|
|
canDownload?: boolean;
|
|
canTranscribe?: boolean;
|
|
isTranscriptionHidden?: boolean;
|
|
isTranscriptionError?: boolean;
|
|
autoPlay?: boolean;
|
|
onHideTranscription?: (isHidden: boolean) => void;
|
|
onPlay?: (messageId: number, chatId: string) => void;
|
|
onPause?: NoneToVoidFunction;
|
|
onReadMedia?: () => void;
|
|
onCancelUpload?: () => void;
|
|
onDateClick?: (arg: ApiMessage) => void;
|
|
};
|
|
|
|
export const TINY_SCREEN_WIDTH_MQL = window.matchMedia('(max-width: 375px)');
|
|
export const WITH_AVATAR_TINY_SCREEN_WIDTH_MQL = window.matchMedia('(max-width: 410px)');
|
|
const AVG_VOICE_DURATION = 10;
|
|
// This is needed for browsers requiring user interaction before playing.
|
|
const PRELOAD = true;
|
|
|
|
const Audio: FC<OwnProps> = ({
|
|
theme,
|
|
message,
|
|
senderTitle,
|
|
uploadProgress,
|
|
origin,
|
|
date,
|
|
noAvatars,
|
|
className,
|
|
isSelectable,
|
|
isSelected,
|
|
isDownloading,
|
|
isTranscribing,
|
|
isTranscriptionHidden,
|
|
isTranscribed,
|
|
isTranscriptionError,
|
|
canDownload,
|
|
canTranscribe,
|
|
autoPlay,
|
|
onHideTranscription,
|
|
onPlay,
|
|
onPause,
|
|
onReadMedia,
|
|
onCancelUpload,
|
|
onDateClick,
|
|
}) => {
|
|
const {
|
|
cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal,
|
|
} = getActions();
|
|
|
|
const {
|
|
content: {
|
|
audio: contentAudio, voice, video,
|
|
}, isMediaUnread,
|
|
} = message;
|
|
const audio = contentAudio || getMessageWebPageAudio(message);
|
|
const media = (voice || video || audio)!;
|
|
const mediaSource = (voice || video);
|
|
const isVoice = Boolean(voice || video);
|
|
const isSeeking = useRef<boolean>(false);
|
|
// eslint-disable-next-line no-null/no-null
|
|
const seekerRef = useRef<HTMLDivElement>(null);
|
|
const lang = useOldLang();
|
|
const { isRtl } = lang;
|
|
|
|
const { isMobile } = useAppLayout();
|
|
const [isActivated, setIsActivated] = useState(false);
|
|
const shouldLoad = isActivated || PRELOAD;
|
|
const coverHash = getMediaHash(media, 'pictogram');
|
|
const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl);
|
|
const hasTtl = hasMessageTtl(message);
|
|
const isInOneTimeModal = origin === AudioOrigin.OneTimeModal;
|
|
const trackType = isVoice ? (hasTtl ? 'oneTimeVoice' : 'voice') : 'audio';
|
|
|
|
const mediaData = useMedia(
|
|
getMediaHash(media, 'inline'),
|
|
!shouldLoad,
|
|
getMediaFormat(media, 'inline'),
|
|
);
|
|
|
|
const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
|
|
getMediaHash(media, 'download'),
|
|
!isDownloading,
|
|
getMediaFormat(media, 'download'),
|
|
);
|
|
|
|
const handleForcePlay = useLastCallback(() => {
|
|
setIsActivated(true);
|
|
onPlay?.(message.id, message.chatId);
|
|
});
|
|
|
|
const handleTrackChange = useLastCallback(() => {
|
|
setIsActivated(false);
|
|
});
|
|
|
|
const {
|
|
isBuffered, bufferedRanges, bufferingHandlers, checkBuffering,
|
|
} = useBuffering();
|
|
|
|
const noReset = isInOneTimeModal;
|
|
const {
|
|
isPlaying, playProgress, playPause, setCurrentTime, duration,
|
|
} = useAudioPlayer(
|
|
makeTrackId(message),
|
|
getMediaDuration(message)!,
|
|
trackType,
|
|
mediaData,
|
|
bufferingHandlers,
|
|
undefined,
|
|
checkBuffering,
|
|
Boolean(isActivated || autoPlay),
|
|
handleForcePlay,
|
|
handleTrackChange,
|
|
isMessageLocal(message) || hasTtl,
|
|
undefined,
|
|
onPause,
|
|
noReset,
|
|
hasTtl && !isInOneTimeModal,
|
|
);
|
|
|
|
const reversePlayProgress = 1 - playProgress;
|
|
const isOwn = isOwnMessage(message);
|
|
const isReverse = hasTtl && isInOneTimeModal;
|
|
|
|
const waveformCanvasRef = useWaveformCanvas(
|
|
theme,
|
|
mediaSource,
|
|
(isMediaUnread && !isOwn && !isReverse) ? 1 : playProgress,
|
|
isOwn,
|
|
!noAvatars,
|
|
isMobile,
|
|
isReverse,
|
|
);
|
|
|
|
const withSeekline = isPlaying || (playProgress > 0 && playProgress < 1);
|
|
|
|
useEffect(() => {
|
|
setIsActivated(isPlaying);
|
|
}, [isPlaying]);
|
|
|
|
const isLoadingForPlaying = isActivated && !isBuffered;
|
|
|
|
const {
|
|
isUploading, isTransferring, transferProgress,
|
|
} = getMediaTransferState(
|
|
uploadProgress || downloadProgress,
|
|
isLoadingForPlaying || isDownloading,
|
|
uploadProgress !== undefined,
|
|
);
|
|
|
|
const {
|
|
shouldRender: shouldRenderSpinner,
|
|
transitionClassNames: spinnerClassNames,
|
|
} = useShowTransitionDeprecated(isTransferring);
|
|
|
|
const shouldRenderCross = shouldRenderSpinner && (isLoadingForPlaying || isUploading);
|
|
|
|
const handleButtonClick = useLastCallback(() => {
|
|
if (isUploading) {
|
|
onCancelUpload?.();
|
|
return;
|
|
}
|
|
|
|
if (hasTtl) {
|
|
openOneTimeMediaModal({ message });
|
|
onReadMedia?.();
|
|
return;
|
|
}
|
|
|
|
if (!isPlaying) {
|
|
onPlay?.(message.id, message.chatId);
|
|
}
|
|
|
|
getActions().setAudioPlayerOrigin({ origin });
|
|
setIsActivated(!isActivated);
|
|
playPause();
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (onReadMedia && isMediaUnread && (isPlaying || isDownloading)) {
|
|
onReadMedia();
|
|
}
|
|
}, [isPlaying, isMediaUnread, onReadMedia, isDownloading]);
|
|
|
|
const handleDownloadClick = useLastCallback(() => {
|
|
if (isDownloading) {
|
|
cancelMediaDownload({ media });
|
|
} else {
|
|
downloadMedia({ media, originMessage: message });
|
|
}
|
|
});
|
|
|
|
const handleSeek = useLastCallback((e: MouseEvent | TouchEvent) => {
|
|
if (isSeeking.current && seekerRef.current) {
|
|
const { width, left } = seekerRef.current.getBoundingClientRect();
|
|
const clientX = e instanceof MouseEvent ? e.clientX : e.targetTouches[0].clientX;
|
|
e.stopPropagation(); // Prevent Slide-to-Reply activation
|
|
// Prevent track skipping while seeking near end
|
|
setCurrentTime(Math.max(Math.min(duration * ((clientX - left) / width), duration - 0.1), 0.001));
|
|
}
|
|
});
|
|
|
|
const handleStartSeek = useLastCallback((e: MouseEvent | TouchEvent) => {
|
|
if (e instanceof MouseEvent && e.button === 2) return;
|
|
isSeeking.current = true;
|
|
handleSeek(e);
|
|
});
|
|
|
|
const handleStopSeek = useLastCallback(() => {
|
|
isSeeking.current = false;
|
|
});
|
|
|
|
const handleDateClick = useLastCallback(() => {
|
|
onDateClick!(message);
|
|
});
|
|
|
|
const handleTranscribe = useLastCallback(() => {
|
|
transcribeAudio({ chatId: message.chatId, messageId: message.id });
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!seekerRef.current || !withSeekline || isInOneTimeModal) return undefined;
|
|
return captureEvents(seekerRef.current, {
|
|
onCapture: handleStartSeek,
|
|
onRelease: handleStopSeek,
|
|
onClick: handleStopSeek,
|
|
onDrag: handleSeek,
|
|
});
|
|
}, [withSeekline, handleStartSeek, handleSeek, handleStopSeek, isInOneTimeModal]);
|
|
|
|
function renderFirstLine() {
|
|
if (isVoice) {
|
|
return senderTitle || 'Voice';
|
|
}
|
|
|
|
const { title, fileName } = audio!;
|
|
|
|
return title || fileName;
|
|
}
|
|
|
|
function renderSecondLine() {
|
|
if (isVoice) {
|
|
return (
|
|
<div className="meta" dir={isRtl ? 'rtl' : undefined}>
|
|
{formatMediaDuration((voice || video)!.duration)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { performer } = audio!;
|
|
|
|
return (
|
|
<div className="meta" dir={isRtl ? 'rtl' : undefined}>
|
|
{formatMediaDuration(duration)}
|
|
<span className="bullet">•</span>
|
|
{performer && <span className="performer" title={performer}>{renderText(performer)}</span>}
|
|
{performer && senderTitle && <span className="bullet">•</span>}
|
|
{senderTitle && <span title={senderTitle}>{renderText(senderTitle)}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fullClassName = buildClassName(
|
|
'Audio',
|
|
className,
|
|
isInOneTimeModal && 'non-interactive',
|
|
origin === AudioOrigin.Inline && 'inline',
|
|
isOwn && origin === AudioOrigin.Inline && 'own',
|
|
(origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger',
|
|
isSelected && 'audio-is-selected',
|
|
);
|
|
|
|
const buttonClassNames = ['toogle-play-wrapper'];
|
|
if (shouldRenderCross) {
|
|
buttonClassNames.push('loading');
|
|
} else {
|
|
buttonClassNames.push(isPlaying ? 'pause' : 'play');
|
|
}
|
|
|
|
const contentClassName = buildClassName('content', withSeekline && 'with-seekline');
|
|
|
|
function renderWithTitle() {
|
|
return (
|
|
<div className={contentClassName}>
|
|
<div className="content-row">
|
|
<p className="title" dir="auto" title={renderFirstLine()}>{renderText(renderFirstLine())}</p>
|
|
|
|
<div className="message-date">
|
|
{Boolean(date) && (
|
|
<Link
|
|
className="date"
|
|
onClick={handleDateClick}
|
|
>
|
|
{formatPastTimeShort(lang, date * 1000)}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{withSeekline && (
|
|
<div className="meta search-result" dir={isRtl ? 'rtl' : undefined}>
|
|
<span className="duration with-seekline" dir="auto">
|
|
{playProgress < 1 && `${formatMediaDuration(duration * playProgress, duration)}`}
|
|
</span>
|
|
{renderSeekline(playProgress, bufferedRanges, seekerRef)}
|
|
</div>
|
|
)}
|
|
{!withSeekline && renderSecondLine()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTooglePlayWrapper() {
|
|
return (
|
|
<div className={buildClassName(...buttonClassNames)}>
|
|
<Button
|
|
round
|
|
ripple={!isMobile}
|
|
size="smaller"
|
|
className="toggle-play"
|
|
color={coverBlobUrl ? 'translucent-white' : 'primary'}
|
|
ariaLabel={isPlaying ? 'Pause audio' : 'Play audio'}
|
|
onClick={handleButtonClick}
|
|
isRtl={lang.isRtl}
|
|
backgroundImage={coverBlobUrl}
|
|
nonInteractive={isInOneTimeModal}
|
|
>
|
|
{!isInOneTimeModal && <Icon name="play" />}
|
|
{!isInOneTimeModal && <Icon name="pause" />}
|
|
{isInOneTimeModal && (
|
|
<AnimatedIcon
|
|
className="flame"
|
|
tgsUrl={LOCAL_TGS_URLS.Flame}
|
|
nonInteractive
|
|
noLoop={false}
|
|
size={40}
|
|
/>
|
|
)}
|
|
</Button>
|
|
{hasTtl && !isInOneTimeModal && (
|
|
<Icon name="view-once" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={fullClassName} dir={lang.isRtl ? 'rtl' : 'ltr'}>
|
|
{isSelectable && (
|
|
<div className="message-select-control">
|
|
{isSelected && <Icon name="select" />}
|
|
</div>
|
|
)}
|
|
{renderTooglePlayWrapper()}
|
|
{shouldRenderSpinner && (
|
|
<div className={buildClassName('media-loading', spinnerClassNames, shouldRenderCross && 'interactive')}>
|
|
<ProgressSpinner
|
|
progress={transferProgress}
|
|
transparent
|
|
withColor
|
|
size="m"
|
|
onClick={shouldRenderCross ? handleButtonClick : undefined}
|
|
noCross={!shouldRenderCross}
|
|
/>
|
|
</div>
|
|
)}
|
|
{isInOneTimeModal && !shouldRenderSpinner && (
|
|
<div className={buildClassName('media-loading')}>
|
|
<ProgressSpinner
|
|
progress={playProgress}
|
|
transparent
|
|
size="m"
|
|
noCross
|
|
rotationOffset={3 / 4}
|
|
/>
|
|
</div>
|
|
)}
|
|
{audio && canDownload && !isUploading && (
|
|
<Button
|
|
round
|
|
size="tiny"
|
|
className="download-button"
|
|
ariaLabel={isDownloading ? 'Cancel download' : 'Download'}
|
|
onClick={handleDownloadClick}
|
|
>
|
|
<Icon name={isDownloading ? 'close' : 'arrow-down'} />
|
|
</Button>
|
|
)}
|
|
{origin === AudioOrigin.Search && renderWithTitle()}
|
|
{origin !== AudioOrigin.Search && audio && renderAudio(
|
|
lang,
|
|
audio,
|
|
duration,
|
|
isPlaying,
|
|
playProgress,
|
|
bufferedRanges,
|
|
seekerRef,
|
|
(isDownloading || isUploading),
|
|
date,
|
|
transferProgress,
|
|
onDateClick ? handleDateClick : undefined,
|
|
)}
|
|
{origin === AudioOrigin.SharedMedia && mediaSource && renderWithTitle()}
|
|
{(origin === AudioOrigin.Inline || isInOneTimeModal || isTranscribed) && mediaSource && (
|
|
renderVoice(
|
|
mediaSource,
|
|
seekerRef,
|
|
waveformCanvasRef,
|
|
hasTtl ? reversePlayProgress : playProgress,
|
|
isMediaUnread,
|
|
isTranscribing,
|
|
isTranscriptionHidden,
|
|
isTranscribed,
|
|
isTranscriptionError,
|
|
canTranscribe ? handleTranscribe : undefined,
|
|
onHideTranscription,
|
|
origin,
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function getSeeklineSpikeAmounts(isMobile?: boolean, withAvatar?: boolean) {
|
|
return {
|
|
MIN_SPIKES: isMobile ? (TINY_SCREEN_WIDTH_MQL.matches ? 16 : 20) : 25,
|
|
MAX_SPIKES: isMobile
|
|
? (TINY_SCREEN_WIDTH_MQL.matches
|
|
? 35
|
|
: (withAvatar && WITH_AVATAR_TINY_SCREEN_WIDTH_MQL.matches ? 40 : 45))
|
|
: 75,
|
|
};
|
|
}
|
|
|
|
function renderAudio(
|
|
lang: OldLangFn,
|
|
audio: ApiAudio,
|
|
duration: number,
|
|
isPlaying: boolean,
|
|
playProgress: number,
|
|
bufferedRanges: BufferedRange[],
|
|
seekerRef: React.Ref<HTMLElement>,
|
|
showProgress?: boolean,
|
|
date?: number,
|
|
progress?: number,
|
|
handleDateClick?: NoneToVoidFunction,
|
|
) {
|
|
const {
|
|
title, performer, fileName,
|
|
} = audio;
|
|
const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1);
|
|
const { isRtl } = lang;
|
|
|
|
return (
|
|
<div className="content">
|
|
<p className="title" dir="auto" title={title}>{renderText(title || fileName)}</p>
|
|
{showSeekline && (
|
|
<div className="meta" dir={isRtl ? 'rtl' : undefined}>
|
|
<span className="duration with-seekline" dir="auto">
|
|
{formatMediaDuration(duration * playProgress, duration)}
|
|
</span>
|
|
{renderSeekline(playProgress, bufferedRanges, seekerRef)}
|
|
</div>
|
|
)}
|
|
{!showSeekline && showProgress && (
|
|
<div className="meta" dir={isRtl ? 'rtl' : undefined}>
|
|
{progress ? `${getFileSizeString(audio!.size * progress)} / ` : undefined}{getFileSizeString(audio!.size)}
|
|
</div>
|
|
)}
|
|
{!showSeekline && !showProgress && (
|
|
<div className="meta" dir={isRtl ? 'rtl' : undefined}>
|
|
<span className="duration" dir="auto">{formatMediaDuration(duration)}</span>
|
|
{performer && (
|
|
<>
|
|
<span className="bullet">•</span>
|
|
<span className="performer" dir="auto" title={performer}>{renderText(performer)}</span>
|
|
</>
|
|
)}
|
|
{Boolean(date) && (
|
|
<>
|
|
<span className="bullet">•</span>
|
|
<Link className="date" onClick={handleDateClick}>
|
|
{formatMediaDateTime(lang, date * 1000, true)}
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderVoice(
|
|
media: ApiVoice | ApiVideo,
|
|
seekerRef: React.Ref<HTMLDivElement>,
|
|
waveformCanvasRef: React.Ref<HTMLCanvasElement>,
|
|
playProgress: number,
|
|
isMediaUnread?: boolean,
|
|
isTranscribing?: boolean,
|
|
isTranscriptionHidden?: boolean,
|
|
isTranscribed?: boolean,
|
|
isTranscriptionError?: boolean,
|
|
onClickTranscribe?: VoidFunction,
|
|
onHideTranscription?: (isHidden: boolean) => void,
|
|
origin?: AudioOrigin,
|
|
) {
|
|
return (
|
|
<div className="content">
|
|
<div className="waveform-wrapper">
|
|
<div
|
|
className="waveform"
|
|
draggable={false}
|
|
ref={seekerRef}
|
|
>
|
|
<canvas ref={waveformCanvasRef} />
|
|
</div>
|
|
{onClickTranscribe && (
|
|
// eslint-disable-next-line react/jsx-no-bind
|
|
<Button onClick={() => {
|
|
if ((isTranscribed || isTranscriptionError) && onHideTranscription) {
|
|
onHideTranscription(!isTranscriptionHidden);
|
|
} else if (!isTranscribing) {
|
|
onClickTranscribe();
|
|
}
|
|
}}
|
|
>
|
|
<Icon
|
|
name={(isTranscribed || isTranscriptionError) ? 'down' : 'transcribe'}
|
|
className={buildClassName(
|
|
'transcribe-icon',
|
|
(isTranscribed || isTranscriptionError) && !isTranscriptionHidden && 'transcribe-shown',
|
|
)}
|
|
/>
|
|
{isTranscribing && (
|
|
<svg viewBox="0 0 32 24" className="loading-svg">
|
|
<rect
|
|
className="loading-rect"
|
|
fill="transparent"
|
|
width="32"
|
|
height="24"
|
|
stroke-width="3"
|
|
stroke-linejoin="round"
|
|
rx="6"
|
|
ry="6"
|
|
stroke="white"
|
|
stroke-dashoffset="1"
|
|
stroke-dasharray="32,68"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p
|
|
className={buildClassName('voice-duration', origin !== AudioOrigin.OneTimeModal && isMediaUnread && 'unread')}
|
|
dir="auto"
|
|
>
|
|
{playProgress === 0 || playProgress === 1
|
|
? formatMediaDuration(media!.duration) : formatMediaDuration(media!.duration * playProgress)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useWaveformCanvas(
|
|
theme: ISettings['theme'],
|
|
media?: ApiVoice | ApiVideo,
|
|
playProgress = 0,
|
|
isOwn = false,
|
|
withAvatar = false,
|
|
isMobile = false,
|
|
isReverse = false,
|
|
) {
|
|
// eslint-disable-next-line no-null/no-null
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
const { data: spikes, peak } = useMemo(() => {
|
|
if (!media) {
|
|
return undefined;
|
|
}
|
|
|
|
const { waveform, duration } = media;
|
|
if (!waveform) {
|
|
return {
|
|
data: new Array(Math.min(duration, MAX_EMPTY_WAVEFORM_POINTS)).fill(0),
|
|
peak: 0,
|
|
};
|
|
}
|
|
|
|
const { MIN_SPIKES, MAX_SPIKES } = getSeeklineSpikeAmounts(isMobile, withAvatar);
|
|
const durationFactor = Math.min(duration / AVG_VOICE_DURATION, 1);
|
|
const spikesCount = Math.round(MIN_SPIKES + (MAX_SPIKES - MIN_SPIKES) * durationFactor);
|
|
const decodedWaveform = decodeWaveform(new Uint8Array(waveform));
|
|
|
|
return interpolateArray(decodedWaveform, spikesCount);
|
|
}, [isMobile, media, withAvatar]) || {};
|
|
|
|
useLayoutEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
|
|
if (!canvas || !spikes || peak === undefined) {
|
|
return;
|
|
}
|
|
|
|
const fillColor = theme === 'dark' ? '#494A78' : '#ADD3F7';
|
|
const fillOwnColor = theme === 'dark' ? '#B7ABED' : '#AEDFA4';
|
|
const progressFillColor = theme === 'dark' ? '#8774E1' : '#3390EC';
|
|
const progressFillOwnColor = theme === 'dark' ? '#FFFFFF' : '#4FAE4E';
|
|
|
|
const fillStyle = isOwn ? fillOwnColor : fillColor;
|
|
const progressFillStyle = isOwn ? progressFillOwnColor : progressFillColor;
|
|
|
|
renderWaveform(canvas, spikes, isReverse ? 1 - playProgress : playProgress, {
|
|
peak,
|
|
fillStyle,
|
|
progressFillStyle,
|
|
});
|
|
}, [isOwn, peak, playProgress, spikes, theme, isReverse]);
|
|
|
|
return canvasRef;
|
|
}
|
|
|
|
function renderSeekline(
|
|
playProgress: number,
|
|
bufferedRanges: BufferedRange[],
|
|
seekerRef: React.Ref<HTMLElement>,
|
|
) {
|
|
return (
|
|
<div
|
|
className="seekline"
|
|
ref={seekerRef as React.Ref<HTMLDivElement>}
|
|
>
|
|
{bufferedRanges.map(({ start, end }) => (
|
|
<div
|
|
className="seekline-buffered-progress"
|
|
style={`left: ${start * 100}%; right: ${100 - end * 100}%`}
|
|
/>
|
|
))}
|
|
<span className="seekline-play-progress">
|
|
<i
|
|
className="seekline-play-progress-inner"
|
|
style={`transform: translateX(${playProgress * 100}%)`}
|
|
/>
|
|
</span>
|
|
<span className="seekline-thumb">
|
|
<i
|
|
className="seekline-thumb-inner"
|
|
style={`transform: translateX(${playProgress * 100}%)`}
|
|
/>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default memo(Audio);
|