From 37f5d026bb1ecf2d91bf8c4f67f10a512fa54cfc Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 6 Sep 2021 15:29:05 +0300 Subject: [PATCH] Audio, Video Player: Better seeking, fix ordering, support covers and Media Session, redesign (#1306) --- package-lock.json | 6 + package.json | 1 + src/api/gramjs/apiBuilders/messages.ts | 7 +- src/api/types/messages.ts | 1 + src/assets/telegram-logo-filled.svg | 1 + src/components/common/Audio.scss | 132 ++++++--- src/components/common/Audio.tsx | 204 ++++++++------ src/components/left/search/AudioResults.tsx | 6 +- src/components/left/search/LeftSearch.scss | 4 - src/components/main/Main.tsx | 8 +- src/components/mediaViewer/VideoPlayer.tsx | 6 +- .../mediaViewer/VideoPlayerControls.scss | 16 +- .../mediaViewer/VideoPlayerControls.tsx | 56 ++-- src/components/middle/AudioPlayer.scss | 9 +- src/components/middle/AudioPlayer.tsx | 43 ++- src/components/middle/message/Message.scss | 14 +- src/components/middle/message/Message.tsx | 5 +- .../middle/message/MessageMeta.scss | 8 +- .../middle/message/_message-content.scss | 2 +- .../middle/message/hooks/useInnerHandlers.ts | 4 +- src/components/right/Profile.tsx | 6 +- src/components/ui/Button.scss | 48 ++-- src/components/ui/Button.tsx | 14 +- src/components/ui/RangeSlider.scss | 4 + src/global/types.ts | 3 + src/hooks/useAsync.ts | 25 ++ src/hooks/useAudioPlayer.ts | 122 ++++++++- src/hooks/useMessageMediaMetadata.ts | 71 +++++ src/modules/actions/ui/messages.ts | 3 +- src/modules/helpers/messageMedia.ts | 6 +- src/modules/helpers/messages.ts | 6 +- src/types/index.ts | 6 + src/util/audioPlayer.ts | 253 +++++++++++++++--- src/util/dateFormat.ts | 8 +- src/util/imageResize.ts | 102 +++++++ src/util/mediaSession.ts | 125 +++++++++ src/util/patchSafariProgressiveAudio.ts | 5 +- 37 files changed, 1062 insertions(+), 278 deletions(-) create mode 100644 src/assets/telegram-logo-filled.svg create mode 100644 src/hooks/useAsync.ts create mode 100644 src/hooks/useMessageMediaMetadata.ts create mode 100644 src/util/imageResize.ts create mode 100644 src/util/mediaSession.ts diff --git a/package-lock.json b/package-lock.json index d253cb7b..27d3a3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4582,6 +4582,12 @@ "@types/jest": "*" } }, + "@types/wicg-mediasession": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/wicg-mediasession/-/wicg-mediasession-1.1.2.tgz", + "integrity": "sha512-5UJ8tBtgMmIzyJafmfBjq2VcXk0SXqZnNCApepKiqLracDV4+dJdYPZCW0IwaYfOBkY54SmfCOWqMBmLCPrfuQ==", + "dev": true + }, "@types/yargs": { "version": "16.0.4", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", diff --git a/package.json b/package.json index ea215047..858d64a4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/react": "^17.0.17", "@types/react-dom": "^17.0.9", "@types/resize-observer-browser": "^0.1.6", + "@types/wicg-mediasession": "^1.1.2", "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "@webpack-cli/serve": "^1.5.1", diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 0eed19e9..e2f10dc8 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -27,7 +27,7 @@ import { DELETED_COMMENTS_CHANNEL_ID, LOCAL_MESSAGE_ID_BASE, SERVICE_NOTIFICATIO import { pick } from '../../../util/iteratees'; import { getApiChatIdFromMtpPeer } from './chats'; import { buildStickerFromDocument } from './symbols'; -import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; +import { buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromStripped } from './common'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; import { addPhotoToLocalDb, resolveMessageApiChatId } from '../helpers'; @@ -348,8 +348,13 @@ function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined { return undefined; } + const thumbnailSizes = media.document.thumbs && media.document.thumbs + .filter((thumb): thumb is GramJs.PhotoSize => thumb instanceof GramJs.PhotoSize) + .map((thumb) => buildApiPhotoSize(thumb)); + return { fileName: getFilenameFromDocument(media.document, 'audio'), + thumbnailSizes, ...pick(media.document, ['size', 'mimeType']), ...pick(audioAttribute, ['duration', 'performer', 'title']), }; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 45edb0c1..e5ff6896 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -68,6 +68,7 @@ export interface ApiAudio { duration: number; performer?: string; title?: string; + thumbnailSizes?: ApiPhotoSize[]; } export interface ApiVoice { diff --git a/src/assets/telegram-logo-filled.svg b/src/assets/telegram-logo-filled.svg new file mode 100644 index 00000000..153c8103 --- /dev/null +++ b/src/assets/telegram-logo-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/common/Audio.scss b/src/components/common/Audio.scss index 4e5bddc0..075c17f8 100644 --- a/src/components/common/Audio.scss +++ b/src/components/common/Audio.scss @@ -1,9 +1,15 @@ .Audio { display: flex; align-items: flex-start; + position: relative; - &.media-inner { - overflow: visible; + .media-loading { + position: absolute; + pointer-events: none; + + &.interactive { + pointer-events: all; + } } &.own { @@ -70,21 +76,13 @@ } } - .media-loading { - pointer-events: none; - - .interactive { - pointer-events: auto; - } - } - .download-button { position: absolute; - width: 1.125rem !important; - height: 1.125rem !important; + width: 1.3125rem !important; + height: 1.3125rem !important; padding: 0; - left: 1.5rem; - top: 1.5rem; + left: 1.8rem; + top: 1.8rem; border: .125rem solid var(--background-color); z-index: 1; @@ -115,6 +113,7 @@ font-weight: 500; margin: 0; line-height: 1.25; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -123,12 +122,30 @@ margin: .25rem 0 0; font-size: .875rem; color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; - span { - margin-left: 0.25rem; - font-size: 1.5rem; - line-height: .875rem; - vertical-align: middle; + .unread { + display: block; + position: relative; + margin-left: 0.5rem; + + &::before { + content: ""; + display: block; + position: absolute; + + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + + background-color: var(--color-text-secondary); + } } } @@ -139,41 +156,69 @@ .waveform { cursor: pointer; margin-left: 1px; + touch-action: none; } .meta, .performer, .date { font-size: .875rem; - line-height: 1; color: var(--color-text-secondary); margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .duration { - margin: .1875rem 0 0; font-size: .875rem; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + } + + .meta { + display: flex; + align-items: center; + margin-top: 0.125rem; + padding-inline-end: 0.5rem; + + & > span { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .performer { + flex-shrink: 0; + } + + .duration.with-seekline { + margin-inline-end: 0.625rem; + } + + .bullet { + margin: 0 0.25rem; + flex-shrink: 0; + } } .seekline { - width: calc(100% - 2px); - padding-left: 6px; - margin-bottom: .3125rem; - height: 12px; + flex-grow: 1; + height: 1.25rem; position: relative; - margin-left: -6px; - top: 1px; + top: 3px; cursor: pointer; + touch-action: none; &::before { content: ''; position: absolute; width: 100%; - left: 6px; top: 6px; height: 2px; background-color: var(--color-interactive-inactive); @@ -188,7 +233,6 @@ overflow: hidden; width: 100%; top: 6px; - left: 6px; i { position: absolute; @@ -220,7 +264,7 @@ content: ''; position: absolute; top: -6px; - right: -12px; + right: -6px; width: 12px; height: 12px; border-radius: 6px; @@ -230,10 +274,6 @@ } &.bigger { - .content { - margin-top: .1875rem; - } - .title { white-space: nowrap; overflow: hidden; @@ -242,17 +282,15 @@ line-height: 1.5rem; } - .meta, + .meta { + height: 1.25rem; + } + .performer, .date { line-height: 1.0625rem; } - .seekline { - top: 2px; - margin-bottom: .5rem; - } - .duration { line-height: 1rem; } @@ -276,6 +314,10 @@ } &[dir=rtl] { + &:last-child { + margin-bottom: 0.625rem; + } + .toggle-play { margin-left: .5rem; margin-right: 0; @@ -286,6 +328,10 @@ } } + .meta.duration.with-seekline { + margin-inline-start: 0.625rem; + } + .content, .duration { text-align: right; @@ -293,7 +339,11 @@ .download-button { left: auto; - right: 1.5rem; + right: 2rem; } } } + +.has-replies .Audio[dir=rtl] { + margin-bottom: 1.625rem; +} diff --git a/src/components/common/Audio.tsx b/src/components/common/Audio.tsx index fa64a114..30a9cca4 100644 --- a/src/components/common/Audio.tsx +++ b/src/components/common/Audio.tsx @@ -3,9 +3,9 @@ import React, { } from '../../lib/teact/teact'; import { - ApiAudio, ApiMessage, ApiVoice, + ApiAudio, ApiMediaFormat, ApiMessage, ApiVoice, } from '../../api/types'; -import { ISettings } from '../../types'; +import { AudioOrigin, ISettings } from '../../types'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { formatMediaDateTime, formatMediaDuration, formatPastTimeShort } from '../../util/dateFormat'; @@ -13,7 +13,6 @@ import { getMediaDuration, getMediaTransferState, getMessageAudioCaption, - getMessageKey, getMessageMediaFormat, getMessageMediaHash, isMessageLocal, @@ -22,6 +21,7 @@ import { import { renderWaveformToDataUri } from './helpers/waveform'; import buildClassName from '../../util/buildClassName'; import renderText from './helpers/renderText'; +import { getFileSizeString } from './helpers/documentInfo'; import { decodeWaveform, interpolateArray } from '../../util/waveform'; import useMediaWithDownloadProgress from '../../hooks/useMediaWithDownloadProgress'; import useShowTransition from '../../hooks/useShowTransition'; @@ -29,6 +29,10 @@ import useBuffering from '../../hooks/useBuffering'; import useAudioPlayer from '../../hooks/useAudioPlayer'; import useMediaDownload from '../../hooks/useMediaDownload'; import useLang, { LangFn } from '../../hooks/useLang'; +import { captureEvents } from '../../util/captureEvents'; +import useMedia from '../../hooks/useMedia'; +import { makeTrackId } from '../../util/audioPlayer'; +import { getTranslation } from '../../util/langProvider'; import Button from '../ui/Button'; import ProgressSpinner from '../ui/ProgressSpinner'; @@ -41,7 +45,7 @@ type OwnProps = { message: ApiMessage; senderTitle?: string; uploadProgress?: number; - target?: 'searchResult' | 'sharedMedia'; + origin: AudioOrigin; date?: number; lastSyncTime?: number; className?: string; @@ -53,12 +57,6 @@ type OwnProps = { 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; @@ -70,7 +68,7 @@ const Audio: FC = ({ message, senderTitle, uploadProgress, - target, + origin, date, lastSyncTime, className, @@ -84,10 +82,16 @@ const Audio: FC = ({ const { content: { audio, voice }, isMediaUnread } = message; const isVoice = Boolean(voice); const isSeeking = useRef(false); + const playStateBeforeSeeking = useRef(false); + // eslint-disable-next-line no-null/no-null + const seekerRef = useRef(null); const lang = useLang(); + const { isRtl } = lang; const [isActivated, setIsActivated] = useState(false); const shouldDownload = (isActivated || PRELOAD) && lastSyncTime; + const coverHash = getMessageMediaHash(message, 'pictogram'); + const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); const { mediaData, downloadProgress } = useMediaWithDownloadProgress( getMessageMediaHash(message, 'inline'), @@ -95,28 +99,38 @@ const Audio: FC = ({ getMessageMediaFormat(message, 'inline'), ); - function handleForcePlay() { + const handleForcePlay = useCallback(() => { setIsActivated(true); onPlay(message.id, message.chatId); - } + }, [message, onPlay]); + + const handleTrackChange = useCallback(() => { + setIsActivated(false); + }, []); const { isBuffered, bufferedProgress, bufferingHandlers, checkBuffering, } = useBuffering(); const { - isPlaying, playProgress, playPause, setCurrentTime, duration, + isPlaying, playProgress, playPause, play, pause, setCurrentTime, duration, } = useAudioPlayer( - getMessageKey(message), + makeTrackId(message), getMediaDuration(message)!, + isVoice ? 'voice' : 'audio', + origin, mediaData, bufferingHandlers, + undefined, checkBuffering, isActivated, handleForcePlay, + handleTrackChange, isMessageLocal(message), ); + const withSeekline = isPlaying || (playProgress > 0 && playProgress < 1); + useEffect(() => { setIsActivated(isPlaying); }, [isPlaying]); @@ -143,7 +157,7 @@ const Audio: FC = ({ } = useShowTransition(isTransferring); const handleButtonClick = useCallback(() => { - if (isUploading) { + if (isUploading && !isPlaying) { if (onCancelUpload) { onCancelUpload(); } @@ -165,29 +179,43 @@ const Audio: FC = ({ } }, [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)); - } + const handleSeek = useCallback((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)); } }, [duration, setCurrentTime]); - const handleStartSeek = useCallback((e: React.MouseEvent) => { + const handleStartSeek = useCallback((e: MouseEvent | TouchEvent) => { + if (e instanceof MouseEvent && e.button === 2) return; isSeeking.current = true; + playStateBeforeSeeking.current = isPlaying; + pause(); handleSeek(e); - }, [handleSeek]); + }, [handleSeek, pause, isPlaying]); const handleStopSeek = useCallback(() => { isSeeking.current = false; - }, []); + if (playStateBeforeSeeking.current) play(); + }, [play]); const handleDateClick = useCallback(() => { onDateClick!(message.id, message.chatId); }, [onDateClick, message.id, message.chatId]); + useEffect(() => { + if (!seekerRef.current || !withSeekline) return undefined; + return captureEvents(seekerRef.current, { + onCapture: handleStartSeek, + onRelease: handleStopSeek, + onClick: handleStopSeek, + onDrag: handleSeek, + }); + }, [withSeekline, handleStartSeek, handleSeek, handleStopSeek]); + function getFirstLine() { if (isVoice) { return senderTitle || 'Voice'; @@ -200,32 +228,37 @@ const Audio: FC = ({ function getSecondLine() { if (isVoice) { - return formatMediaDuration(voice!.duration); + return ( +
+ {formatMediaDuration(voice!.duration)} +
+ ); } const { performer } = audio!; return ( - <> - {performer && renderText(performer)} - {performer && senderTitle && } - {senderTitle && renderText(senderTitle)} - +
+ {formatMediaDuration(duration)} + + {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, { handleStartSeek, handleSeek, handleStopSeek }, theme), - [voice, playProgress, isOwn, handleStartSeek, handleSeek, handleStopSeek, theme], + () => voice && renderWaveform(voice, playProgress, isOwn, theme, seekerRef), + [voice, playProgress, isOwn, theme], ); const fullClassName = buildClassName( - 'Audio media-inner', + 'Audio', className, - isOwn && !target && 'own', - target && 'bigger', + isOwn && origin === AudioOrigin.Inline && 'own', + (origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger', isSelected && 'audio-is-selected', ); @@ -238,15 +271,14 @@ const Audio: FC = ({ buttonClassNames.push('play'); } - const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1); - const contentClassName = buildClassName('content', showSeekline && 'with-seekline'); + const contentClassName = buildClassName('content', withSeekline && 'with-seekline'); function renderSearchResult() { return ( <>
-

{renderText(getFirstLine())}

+

{renderText(getFirstLine())}

{date && ( @@ -260,13 +292,15 @@ const Audio: FC = ({
- {showSeekline && renderSeekline(playProgress, bufferedProgress, seekHandlers)} - {!showSeekline && ( -

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

+ {withSeekline && ( +
+ + {playProgress < 1 && `${formatMediaDuration(duration * playProgress, duration)}`} + + {renderSeekline(playProgress, bufferedProgress, seekerRef)} +
)} + {!withSeekline && getSecondLine()}
); @@ -282,11 +316,13 @@ const Audio: FC = ({ )} - {target === 'searchResult' && renderSearchResult()} - {target !== 'searchResult' && audio && renderAudio( - lang, audio, isPlaying, playProgress, bufferedProgress, seekHandlers, date, - onDateClick ? handleDateClick : undefined, + {origin === AudioOrigin.Search && renderSearchResult()} + {origin !== AudioOrigin.Search && audio && renderAudio( + lang, audio, duration, isPlaying, playProgress, bufferedProgress, seekerRef, (isDownloadStarted || isUploading), + date, transferProgress, onDateClick ? handleDateClick : undefined, )} - {target !== 'searchResult' && voice && renderVoice(voice, renderedWaveform, isMediaUnread)} + {origin !== AudioOrigin.Search && voice && renderVoice(voice, renderedWaveform, playProgress, isMediaUnread)} ); }; @@ -326,50 +362,62 @@ const Audio: FC = ({ function renderAudio( lang: LangFn, audio: ApiAudio, + duration: number, isPlaying: boolean, playProgress: number, bufferedProgress: number, - seekHandlers: ISeekMethods, + seekerRef: React.Ref, + showProgress?: boolean, date?: number, + progress?: number, handleDateClick?: NoneToVoidFunction, ) { const { - title, performer, duration, fileName, + title, performer, fileName, } = audio; const showSeekline = isPlaying || (playProgress > 0 && playProgress < 1); + const { isRtl } = getTranslation; return (
-

{renderText(title || fileName)}

- {showSeekline && renderSeekline(playProgress, bufferedProgress, seekHandlers)} - {!showSeekline && ( -
- {renderText(performer || 'Unknown')} +

{renderText(title || fileName)}

+ {showSeekline && ( +
+ + {formatMediaDuration(duration * playProgress, duration)} + + {renderSeekline(playProgress, bufferedProgress, seekerRef)} +
+ )} + {!showSeekline && showProgress && ( +
+ {progress ? `${getFileSizeString(audio!.size * progress)} / ` : undefined}{getFileSizeString(audio!.size)} +
+ )} + {!showSeekline && !showProgress && ( +
+ {formatMediaDuration(duration)} + + {renderText(performer || 'Unknown')} {date && ( <> - {' '} - • - {' '} + {formatMediaDateTime(lang, date * 1000)} )}
)} -

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

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

- {formatMediaDuration(voice.duration)} - {isMediaUnread && } + {playProgress === 0 ? formatMediaDuration(voice.duration) : formatMediaDuration(voice.duration * playProgress)} + {isMediaUnread && }

); @@ -379,8 +427,8 @@ function renderWaveform( voice: ApiVoice, playProgress = 0, isOwn = false, - { handleStartSeek, handleSeek, handleStopSeek }: ISeekMethods, theme: ISettings['theme'], + seekerRef: React.Ref, ) { const { waveform, duration } = voice; @@ -411,9 +459,7 @@ function renderWaveform( height={height} className="waveform" draggable={false} - onMouseDown={handleStartSeek} - onMouseMove={handleSeek} - onMouseUp={handleStopSeek} + ref={seekerRef as React.Ref} /> ); } @@ -421,14 +467,12 @@ function renderWaveform( function renderSeekline( playProgress: number, bufferedProgress: number, - { handleStartSeek, handleSeek, handleStopSeek }: ISeekMethods, + seekerRef: React.Ref, ) { return (
} > = ({ }, [focusMessage]); const handlePlayAudio = useCallback((messageId: number, chatId: number) => { - openAudioPlayer({ chatId, messageId }); + openAudioPlayer({ chatId, messageId, origin: AudioOrigin.Search }); }, [openAudioPlayer]); function renderList() { @@ -97,7 +97,7 @@ const AudioResults: FC = ({ key={message.id} theme={theme} message={message} - target="searchResult" + origin={AudioOrigin.Search} senderTitle={getSenderName(lang, message, chatsById, usersById)} date={message.date} lastSyncTime={lastSyncTime} diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index b10b4e59..6b89e1cf 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -96,10 +96,6 @@ } .Audio { - .duration span { - padding: 0 .25rem; - } - .ProgressSpinner { margin: -.1875rem 0 0 -.1875rem; } diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 86f10a5a..04e9cd6c 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -3,6 +3,7 @@ import React, { } from '../../lib/teact/teact'; import { getGlobal, withGlobal } from '../../lib/teact/teactn'; +import { AudioOrigin } from '../../types'; import { GlobalActions } from '../../global/types'; import { ApiMessage } from '../../api/types'; import { LangCode } from '../../types'; @@ -55,6 +56,7 @@ type StateProps = { hasNotifications: boolean; hasDialogs: boolean; audioMessage?: ApiMessage; + audioOrigin?: AudioOrigin; safeLinkModalUrl?: string; isHistoryCalendarOpen: boolean; shouldSkipHistoryAnimations?: boolean; @@ -84,6 +86,7 @@ const Main: FC = ({ hasNotifications, hasDialogs, audioMessage, + audioOrigin, safeLinkModalUrl, isHistoryCalendarOpen, shouldSkipHistoryAnimations, @@ -242,7 +245,7 @@ const Main: FC = ({ - {audioMessage && } + {audioMessage && } { - const { chatId: audioChatId, messageId: audioMessageId } = global.audioPlayer; + const { chatId: audioChatId, messageId: audioMessageId, origin } = global.audioPlayer; const audioMessage = audioChatId && audioMessageId ? selectChatMessage(global, audioChatId, audioMessageId) : undefined; @@ -292,6 +295,7 @@ export default memo(withGlobal( hasNotifications: Boolean(global.notifications.length), hasDialogs: Boolean(global.dialogs.length), audioMessage, + audioOrigin: origin, safeLinkModalUrl: global.safeLinkModalUrl, isHistoryCalendarOpen: Boolean(global.historyCalendarSelectedAt), shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations, diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index 9ead5304..215f8836 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -118,10 +118,8 @@ const VideoPlayer: FC = ({ } }, [exitFullscreen, isFullscreen, setFullscreen]); - const handleSeek = useCallback((e: React.ChangeEvent) => { - e.stopPropagation(); - - videoRef.current!.currentTime = (Number(e.target.value) * videoRef.current!.duration) / 100; + const handleSeek = useCallback((position: number) => { + videoRef.current!.currentTime = position; }, []); const toggleControls = useCallback((e: React.MouseEvent) => { diff --git a/src/components/mediaViewer/VideoPlayerControls.scss b/src/components/mediaViewer/VideoPlayerControls.scss index 54e6e1da..437934c0 100644 --- a/src/components/mediaViewer/VideoPlayerControls.scss +++ b/src/components/mediaViewer/VideoPlayerControls.scss @@ -101,7 +101,8 @@ right: 1rem; top: 0; height: 1rem; - + touch-action: none; + cursor: pointer; &-track { position: absolute; @@ -143,18 +144,5 @@ transform: translate(.325rem, -50%); } } - - &-input { - width: 100%; - opacity: 0; - margin: 0; - padding: 0; - cursor: pointer; - overflow: hidden; - - &::-webkit-slider-thumb { - margin-top: -2rem; - } - } } } diff --git a/src/components/mediaViewer/VideoPlayerControls.tsx b/src/components/mediaViewer/VideoPlayerControls.tsx index 6322844c..440fd598 100644 --- a/src/components/mediaViewer/VideoPlayerControls.tsx +++ b/src/components/mediaViewer/VideoPlayerControls.tsx @@ -1,9 +1,12 @@ -import React, { FC, useState, useEffect } from '../../lib/teact/teact'; +import React, { + FC, useState, useEffect, useRef, useCallback, +} from '../../lib/teact/teact'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { formatMediaDuration } from '../../util/dateFormat'; import formatFileSize from './helpers/formatFileSize'; import useLang from '../../hooks/useLang'; +import { captureEvents } from '../../util/captureEvents'; import Button from '../ui/Button'; @@ -21,11 +24,9 @@ type IProps = { isFullscreen: boolean; onChangeFullscreen: (e: React.MouseEvent) => void; onPlayPause: (e: React.MouseEvent) => void; - onSeek: OnChangeHandler; + onSeek: (position: number) => void; }; -type OnChangeHandler = (e: React.ChangeEvent) => void; - const stopEvent = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -47,6 +48,9 @@ const VideoPlayerControls: FC = ({ onSeek, }) => { const [isVisible, setVisibility] = useState(true); + // eslint-disable-next-line no-null/no-null + const seekerRef = useRef(null); + const isSeeking = useRef(false); useEffect(() => { if (isForceVisible) { @@ -86,13 +90,40 @@ const VideoPlayerControls: FC = ({ const lang = useLang(); + const handleSeek = useCallback((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; + onSeek(Math.max(Math.min(duration * ((clientX - left) / width), duration), 0)); + } + }, [duration, onSeek]); + + const handleStartSeek = useCallback((e: MouseEvent | TouchEvent) => { + isSeeking.current = true; + handleSeek(e); + }, [handleSeek]); + + const handleStopSeek = useCallback(() => { + isSeeking.current = false; + }, []); + + useEffect(() => { + if (!seekerRef.current || !isVisible) return undefined; + return captureEvents(seekerRef.current, { + onCapture: handleStartSeek, + onRelease: handleStopSeek, + onClick: handleStopSeek, + onDrag: handleSeek, + }); + }, [isVisible, handleStartSeek, handleSeek, handleStopSeek]); + if (!isVisible && !isForceVisible) { return undefined; } return (
- {renderSeekLine(currentTime, duration, bufferedProgress, onSeek)} + {renderSeekLine(currentTime, duration, bufferedProgress, seekerRef)}