import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { ApiAudio, ApiMessage, ApiVoice, } from '../../api/types'; import { ISettings } from '../../types'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '../../util/dateFormat'; import { getMediaDuration, getMediaTransferState, getMessageAudioCaption, getMessageKey, getMessageMediaFormat, getMessageMediaHash, isMessageLocal, isOwnMessage, } from '../../modules/helpers'; import { renderWaveformToDataUri } from './helpers/waveform'; import buildClassName from '../../util/buildClassName'; import renderText from './helpers/renderText'; import { decodeWaveform, interpolateArray } from '../../util/waveform'; import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useShowTransition from '../../hooks/useShowTransition'; import useBuffering from '../../hooks/useBuffering'; import useAudioPlayer from '../../hooks/useAudioPlayer'; import useMediaDownload from '../../hooks/useMediaDownload'; import useLang, { LangFn } from '../../hooks/useLang'; import Button from '../ui/Button'; import ProgressSpinner from '../ui/ProgressSpinner'; import Link from '../ui/Link'; import './Audio.scss'; type OwnProps = { theme: ISettings['theme']; message: ApiMessage; senderTitle?: string; uploadProgress?: number; target?: 'searchResult' | 'sharedMedia'; date?: number; lastSyncTime?: number; className?: string; isSelectable?: boolean; isSelected?: boolean; onPlay: (messageId: number, chatId: number) => void; onReadMedia?: () => void; onCancelUpload?: () => void; onDateClick?: (messageId: number, chatId: number) => void; }; interface ISeekMethods { handleStartSeek: (e: React.MouseEvent) => void; handleSeek: (e: React.MouseEvent) => void; handleStopSeek: () => void; } const AVG_VOICE_DURATION = 30; const MIN_SPIKES = IS_SINGLE_COLUMN_LAYOUT ? 20 : 25; const MAX_SPIKES = IS_SINGLE_COLUMN_LAYOUT ? 50 : 75; // This is needed for browsers requiring user interaction before playing. const PRELOAD = true; const Audio: FC = ({ theme, message, senderTitle, uploadProgress, target, date, lastSyncTime, className, isSelectable, isSelected, onPlay, onReadMedia, onCancelUpload, onDateClick, }) => { const { content: { audio, voice }, isMediaUnread } = message; const isVoice = Boolean(voice); const isSeeking = useRef(false); const lang = useLang(); const [isActivated, setIsActivated] = useState(false); const shouldDownload = (isActivated || PRELOAD) && lastSyncTime; const { mediaData, downloadProgress } = useMediaWithDownloadProgress( getMessageMediaHash(message, 'inline'), !shouldDownload, getMessageMediaFormat(message, 'inline'), ); function handleForcePlay() { setIsActivated(true); onPlay(message.id, message.chatId); } const { isBuffered, bufferedProgress, bufferingHandlers, checkBuffering, } = useBuffering(); const { isPlaying, playProgress, playPause, setCurrentTime, duration, } = useAudioPlayer( getMessageKey(message), getMediaDuration(message)!, mediaData, bufferingHandlers, checkBuffering, isActivated, handleForcePlay, isMessageLocal(message), ); useEffect(() => { setIsActivated(isPlaying); }, [isPlaying]); const { isDownloadStarted, downloadProgress: directDownloadProgress, handleDownloadClick, } = useMediaDownload(getMessageMediaHash(message, 'download'), getMessageAudioCaption(message)); const isLoadingForPlaying = isActivated && !isBuffered; const { isUploading, isTransferring, transferProgress, } = getMediaTransferState( message, isDownloadStarted ? directDownloadProgress : (uploadProgress || downloadProgress), isLoadingForPlaying || isDownloadStarted, ); const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, } = useShowTransition(isTransferring); const handleButtonClick = useCallback(() => { if (isUploading) { if (onCancelUpload) { onCancelUpload(); } return; } if (!isPlaying) { onPlay(message.id, message.chatId); } setIsActivated(!isActivated); playPause(); }, [isPlaying, isUploading, message.id, message.chatId, onCancelUpload, onPlay, playPause, isActivated]); useEffect(() => { if (isPlaying && onReadMedia && isMediaUnread) { onReadMedia(); } }, [isPlaying, isMediaUnread, onReadMedia]); const handleSeek = useCallback((e: React.MouseEvent) => { if (isSeeking.current) { const seekBar = e.currentTarget.closest('.seekline,.waveform'); if (seekBar) { const { width, left } = seekBar.getBoundingClientRect(); setCurrentTime(duration * ((e.clientX - left) / width)); } } }, [duration, setCurrentTime]); const handleStartSeek = useCallback((e: React.MouseEvent) => { isSeeking.current = true; handleSeek(e); }, [handleSeek]); const handleStopSeek = useCallback(() => { isSeeking.current = false; }, []); const handleDateClick = useCallback(() => { onDateClick!(message.id, message.chatId); }, [onDateClick, message.id, message.chatId]); function getFirstLine() { if (isVoice) { return senderTitle || 'Voice'; } const { title, fileName } = audio!; return title || fileName; } function getSecondLine() { if (isVoice) { return formatMediaDuration(voice!.duration); } const { performer } = audio!; return ( <> {performer && renderText(performer)} {performer && senderTitle && } {senderTitle && renderText(senderTitle)} ); } const seekHandlers = { handleStartSeek, handleSeek, handleStopSeek }; const isOwn = isOwnMessage(message); const renderedWaveform = useMemo( () => voice && renderWaveform(voice, playProgress, isOwn, seekHandlers, theme), [voice, playProgress, isOwn, seekHandlers, theme], ); const fullClassName = buildClassName( 'Audio media-inner', className, isOwn && !target && 'own', target && 'bigger', isSelected && 'audio-is-selected', ); const buttonClassNames = ['toggle-play']; if (isLoadingForPlaying) { buttonClassNames.push('loading'); } else if (isPlaying) { buttonClassNames.push('pause'); } else if (!isPlaying) { buttonClassNames.push('play'); } const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1); const contentClassName = buildClassName('content', showSeekline && 'with-seekline'); function renderSearchResult() { return ( <>

{renderText(getFirstLine())}

{date && ( {formatPastTimeShort(lang, date * 1000)} )}
{showSeekline && renderSeekline(playProgress, bufferedProgress, seekHandlers)} {!showSeekline && (

{playProgress > 0 ? `${formatMediaDuration(duration * playProgress)} / ` : undefined} {getSecondLine()}

)}
); } return (
{isSelectable && (
{isSelected && }
)} {shouldRenderSpinner && (
)} {audio && ( )} {target === 'searchResult' && renderSearchResult()} {target !== 'searchResult' && audio && renderAudio( lang, audio, isPlaying, playProgress, bufferedProgress, seekHandlers, date, onDateClick ? handleDateClick : undefined, )} {target !== 'searchResult' && voice && renderVoice(voice, renderedWaveform, isMediaUnread)}
); }; function renderAudio( lang: LangFn, audio: ApiAudio, isPlaying: boolean, playProgress: number, bufferedProgress: number, seekHandlers: ISeekMethods, date?: number, handleDateClick?: NoneToVoidFunction, ) { const { title, performer, duration, fileName, } = audio; const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1); return (

{renderText(title || fileName)}

{showSeekline && renderSeekline(playProgress, bufferedProgress, seekHandlers)} {!showSeekline && (
{renderText(performer || 'Unknown')} {date && ( <> {' '} • {' '} {formatMediaDateTime(lang, date * 1000)} )}
)}

{playProgress > 0 ? `${formatMediaDuration(duration * playProgress)} / ` : undefined} {formatMediaDuration(duration)}

); } function renderVoice(voice: ApiVoice, renderedWaveform: any, isMediaUnread?: boolean) { return (
{renderedWaveform}

{formatMediaDuration(voice.duration)} {isMediaUnread && }

); } function renderWaveform( voice: ApiVoice, playProgress = 0, isOwn = false, { handleStartSeek, handleSeek, handleStopSeek }: ISeekMethods, theme: ISettings['theme'], ) { const { waveform, duration } = voice; if (!waveform) { return undefined; } const fillColor = theme === 'dark' ? '#494B75' : '#CBCBCB'; const fillOwnColor = theme === 'dark' ? '#C0BBED' : '#B0DEA6'; const progressFillColor = theme === 'dark' ? '#868DF5' : '#54a3e6'; const progressFillOwnColor = theme === 'dark' ? '#FFFFFF' : '#53ad53'; 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)); const { data: spikes, peak } = interpolateArray(decodedWaveform, spikesCount); const { src, width, height } = renderWaveformToDataUri(spikes, playProgress, { peak, fillStyle: isOwn ? fillOwnColor : fillColor, progressFillStyle: isOwn ? progressFillOwnColor : progressFillColor, }); return ( // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions ); } function renderSeekline( playProgress: number, bufferedProgress: number, { handleStartSeek, handleSeek, handleStopSeek }: ISeekMethods, ) { return (
); } export default memo(Audio);