import type { ElementRef } from '../../lib/teact/teact'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { ApiAudio, ApiMessage, ApiVideo, ApiVoice, ApiWebPage, } from '../../api/types'; import type { BufferedRange } from '../../hooks/useBuffering'; import type { OldLangFn } from '../../hooks/useOldLang'; import type { ThemeKey } from '../../types'; import type { LangFn } from '../../util/localization'; import { ApiMediaFormat } from '../../api/types'; import { AudioOrigin } from '../../types'; import { getMediaFormat, getMediaHash, getMediaTransferState, getWebPageAudio, hasMessageTtl, isMessageLocal, isOwnMessage, } from '../../global/helpers'; import { selectWebPageFromMessage } from '../../global/selectors'; import { selectMessageMediaDuration } from '../../global/selectors/media'; import { makeTrackId } from '../../util/audioPlayer'; import buildClassName from '../../util/buildClassName'; import { captureEvents } from '../../util/captureEvents'; import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '../../util/dates/oldDateFormat'; import { decodeWaveform, interpolateArray } from '../../util/waveform'; import { LOCAL_TGS_URLS } from './helpers/animatedAssets'; 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 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 AnimatedFileSize from './AnimatedFileSize'; import AnimatedIcon from './AnimatedIcon'; import Icon from './icons/Icon'; import './Audio.scss'; type OwnProps = { theme: ThemeKey; 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; }; type StateProps = { mediaDuration?: number; webPage?: ApiWebPage; }; 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 = ({ theme, message, senderTitle, uploadProgress, origin, date, noAvatars, className, isSelectable, isSelected, isDownloading, isTranscribing, isTranscriptionHidden, isTranscribed, isTranscriptionError, canDownload, canTranscribe, autoPlay, webPage, mediaDuration, onHideTranscription, onPlay, onPause, onReadMedia, onCancelUpload, onDateClick, }: OwnProps & StateProps) => { const { cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal, } = getActions(); const { content: { audio: contentAudio, voice, video, }, isMediaUnread, } = message; const audio = contentAudio || getWebPageAudio(webPage); const media = (voice || video || audio)!; const mediaSource = (voice || video); const isVoice = Boolean(voice || video); const isSeekingRef = useRef(false); const seekerRef = useRef(); const oldLang = useOldLang(); const lang = useLang(); 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), mediaDuration!, 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) { onReadMedia(); } }, [isPlaying, isMediaUnread, onReadMedia]); const handleDownloadClick = useLastCallback(() => { if (isDownloading) { cancelMediaDownload({ media }); } else { downloadMedia({ media, originMessage: message }); } }); const handleSeek = useLastCallback((e: MouseEvent | TouchEvent) => { if (isSeekingRef.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; isSeekingRef.current = true; handleSeek(e); }); const handleStopSeek = useLastCallback(() => { isSeekingRef.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 (
{formatMediaDuration((voice || video)!.duration)}
); } const { performer } = audio!; return (
{formatMediaDuration(duration)} {performer && {renderText(performer)}} {performer && senderTitle && } {senderTitle && {renderText(senderTitle)}}
); } 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 (

{renderText(renderFirstLine())}

{Boolean(date) && ( {formatPastTimeShort(oldLang, date * 1000)} )}
{withSeekline && (
{playProgress < 1 && formatMediaDuration(duration * playProgress, duration)} {renderSeekline(playProgress, bufferedRanges, seekerRef)}
)} {!withSeekline && renderSecondLine()}
); } function renderTooglePlayWrapper() { return (
{hasTtl && !isInOneTimeModal && ( )}
); } return (
{isSelectable && (
{isSelected && }
)} {renderTooglePlayWrapper()} {shouldRenderSpinner && (
)} {isInOneTimeModal && !shouldRenderSpinner && (
)} {audio && canDownload && !isUploading && (
); }; 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, oldLang: OldLangFn, audio: ApiAudio, duration: number, isPlaying: boolean, playProgress: number, bufferedRanges: BufferedRange[], seekerRef: ElementRef, 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 && ( )} {!showSeekline && !showProgress && (
{formatMediaDuration(duration)} {performer && ( <> {renderText(performer)} )} {Boolean(date) && ( <> {formatMediaDateTime(oldLang, date * 1000, true)} )}
)}
); } function renderVoice( media: ApiVoice | ApiVideo, seekerRef: ElementRef, waveformCanvasRef: ElementRef, playProgress: number, isMediaUnread?: boolean, isTranscribing?: boolean, isTranscriptionHidden?: boolean, isTranscribed?: boolean, isTranscriptionError?: boolean, onClickTranscribe?: VoidFunction, onHideTranscription?: (isHidden: boolean) => void, origin?: AudioOrigin, ) { return (
{onClickTranscribe && ( )}

{playProgress === 0 || playProgress === 1 ? formatMediaDuration(media.duration) : formatMediaDuration(media.duration * playProgress)}

); } function useWaveformCanvas( theme: ThemeKey, media?: ApiVoice | ApiVideo, playProgress = 0, isOwn = false, withAvatar = false, isMobile = false, isReverse = false, ) { const canvasRef = useRef(); 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: ElementRef, ) { return (
{bufferedRanges.map(({ start, end }) => (
))}
); } export default memo(withGlobal( (global, { message, }): Complete => { const webPage = selectWebPageFromMessage(global, message); const mediaDuration = selectMessageMediaDuration(global, message); return { webPage, mediaDuration, }; }, )(Audio));