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, ApiVoice } from '../../api/types'; import type { BufferedRange } from '../../hooks/useBuffering'; import type { LangFn } from '../../hooks/useLang'; import type { ISettings } from '../../types'; import { ApiMediaFormat } from '../../api/types'; import { AudioOrigin } from '../../types'; import { getMediaDuration, getMediaTransferState, getMessageMediaFormat, getMessageMediaHash, 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/dateFormat'; import { decodeWaveform, interpolateArray } from '../../util/waveform'; 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 useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useShowTransition from '../../hooks/useShowTransition'; import Button from '../ui/Button'; import Link from '../ui/Link'; import ProgressSpinner from '../ui/ProgressSpinner'; 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; onHideTranscription?: (isHidden: boolean) => void; onPlay: (messageId: number, chatId: string) => void; onReadMedia?: () => void; onCancelUpload?: () => void; onDateClick?: (messageId: number, chatId: string) => 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 = ({ theme, message, senderTitle, uploadProgress, origin, date, noAvatars, className, isSelectable, isSelected, isDownloading, isTranscribing, isTranscriptionHidden, isTranscribed, isTranscriptionError, canDownload, canTranscribe, onHideTranscription, onPlay, onReadMedia, onCancelUpload, onDateClick, }) => { const { cancelMessageMediaDownload, downloadMessageMedia, transcribeAudio } = getActions(); const { content: { audio, voice, video }, isMediaUnread } = message; const isVoice = Boolean(voice || video); const isSeeking = useRef(false); // eslint-disable-next-line no-null/no-null const seekerRef = useRef(null); const lang = useLang(); const { isRtl } = lang; const { isMobile } = useAppLayout(); const [isActivated, setIsActivated] = useState(false); const shouldLoad = isActivated || PRELOAD; const coverHash = getMessageMediaHash(message, 'pictogram'); const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); const mediaData = useMedia( getMessageMediaHash(message, 'inline'), !shouldLoad, getMessageMediaFormat(message, 'inline'), ); const { loadProgress: downloadProgress } = useMediaWithLoadProgress( getMessageMediaHash(message, 'download'), !isDownloading, getMessageMediaFormat(message, 'download'), ); const handleForcePlay = useLastCallback(() => { setIsActivated(true); onPlay(message.id, message.chatId); }); const handleTrackChange = useLastCallback(() => { setIsActivated(false); }); const { isBuffered, bufferedRanges, bufferingHandlers, checkBuffering, } = useBuffering(); const { isPlaying, playProgress, playPause, setCurrentTime, duration, } = useAudioPlayer( makeTrackId(message), getMediaDuration(message)!, isVoice ? 'voice' : 'audio', mediaData, bufferingHandlers, undefined, checkBuffering, isActivated, handleForcePlay, handleTrackChange, isMessageLocal(message), ); const isOwn = isOwnMessage(message); const waveformCanvasRef = useWaveformCanvas( theme, voice, (isMediaUnread && !isOwn) ? 1 : playProgress, isOwn, !noAvatars, isMobile, ); const withSeekline = isPlaying || (playProgress > 0 && playProgress < 1); useEffect(() => { setIsActivated(isPlaying); }, [isPlaying]); const isLoadingForPlaying = isActivated && !isBuffered; const { isUploading, isTransferring, transferProgress, } = getMediaTransferState( message, uploadProgress || downloadProgress, isLoadingForPlaying || isDownloading, ); const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, } = useShowTransition(isTransferring); const shouldRenderCross = shouldRenderSpinner && (isLoadingForPlaying || isUploading); const handleButtonClick = useLastCallback(() => { if (isUploading) { onCancelUpload?.(); 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) { cancelMessageMediaDownload({ message }); } else { downloadMessageMedia({ 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.id, message.chatId); }); const handleTranscribe = useLastCallback(() => { transcribeAudio({ chatId: message.chatId, messageId: message.id }); }); useEffect(() => { if (!seekerRef.current || !withSeekline) return undefined; return captureEvents(seekerRef.current, { onCapture: handleStartSeek, onRelease: handleStopSeek, onClick: handleStopSeek, onDrag: handleSeek, }); }, [withSeekline, handleStartSeek, handleSeek, handleStopSeek]); function renderFirstLine() { if (isVoice) { return senderTitle || 'Voice'; } const { title, fileName } = audio!; return title || fileName; } function renderSecondLine() { if (isVoice) { return (
{formatMediaDuration((voice || video)!.duration)}
); } const { performer } = audio!; return (
{formatMediaDuration(duration)} {performer && {renderText(performer)}} {performer && senderTitle && } {senderTitle && {renderText(senderTitle)}}
); } const fullClassName = buildClassName( 'Audio', className, origin === AudioOrigin.Inline && 'inline', isOwn && origin === AudioOrigin.Inline && 'own', (origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger', isSelected && 'audio-is-selected', ); const buttonClassNames = ['toggle-play']; if (shouldRenderCross) { buttonClassNames.push('loading'); } else { buttonClassNames.push(isPlaying ? 'pause' : 'play'); } const contentClassName = buildClassName('content', withSeekline && 'with-seekline'); function renderWithTitle() { return (

{renderText(renderFirstLine())}

{Boolean(date) && ( {formatPastTimeShort(lang, date * 1000)} )}
{withSeekline && (
{playProgress < 1 && `${formatMediaDuration(duration * playProgress, duration)}`} {renderSeekline(playProgress, bufferedRanges, seekerRef)}
)} {!withSeekline && renderSecondLine()}
); } return (
{isSelectable && (
{isSelected && }
)} {shouldRenderSpinner && (
)} {audio && canDownload && !isUploading && ( )} {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 && (voice || video) && renderWithTitle()} {origin === AudioOrigin.Inline && voice && ( renderVoice( voice, seekerRef, waveformCanvasRef, playProgress, isMediaUnread, isTranscribing, isTranscriptionHidden, isTranscribed, isTranscriptionError, canTranscribe ? handleTranscribe : undefined, onHideTranscription, ) )}
); }; 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: LangFn, audio: ApiAudio, duration: number, isPlaying: boolean, playProgress: number, bufferedRanges: BufferedRange[], seekerRef: React.Ref, showProgress?: boolean, date?: number, progress?: number, handleDateClick?: NoneToVoidFunction, ) { const { title, performer, fileName, } = audio; const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1); const { isRtl } = lang; return (

{renderText(title || fileName)}

{showSeekline && (
{formatMediaDuration(duration * playProgress, duration)} {renderSeekline(playProgress, bufferedRanges, seekerRef)}
)} {!showSeekline && showProgress && (
{progress ? `${getFileSizeString(audio!.size * progress)} / ` : undefined}{getFileSizeString(audio!.size)}
)} {!showSeekline && !showProgress && (
{formatMediaDuration(duration)} {performer && ( <> {renderText(performer)} )} {Boolean(date) && ( <> {formatMediaDateTime(lang, date * 1000, true)} )}
)}
); } function renderVoice( voice: ApiVoice, seekerRef: React.Ref, waveformCanvasRef: React.Ref, playProgress: number, isMediaUnread?: boolean, isTranscribing?: boolean, isTranscriptionHidden?: boolean, isTranscribed?: boolean, isTranscriptionError?: boolean, onClickTranscribe?: VoidFunction, onHideTranscription?: (isHidden: boolean) => void, ) { return (
{onClickTranscribe && ( // eslint-disable-next-line react/jsx-no-bind )}

{playProgress === 0 ? formatMediaDuration(voice.duration) : formatMediaDuration(voice.duration * playProgress)}

); } function useWaveformCanvas( theme: ISettings['theme'], voice?: ApiVoice, playProgress = 0, isOwn = false, withAvatar = false, isMobile = false, ) { // eslint-disable-next-line no-null/no-null const canvasRef = useRef(null); const { data: spikes, peak } = useMemo(() => { if (!voice) { return undefined; } const { waveform, duration } = voice; 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, voice, 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'; renderWaveform(canvas, spikes, playProgress, { peak, fillStyle: isOwn ? fillOwnColor : fillColor, progressFillStyle: isOwn ? progressFillOwnColor : progressFillColor, }); }, [isOwn, peak, playProgress, spikes, theme]); return canvasRef; } function renderSeekline( playProgress: number, bufferedRanges: BufferedRange[], seekerRef: React.Ref, ) { return (
} > {bufferedRanges.map(({ start, end }) => (
))}
); } export default memo(Audio);