385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import type { FC } from '../../lib/teact/teact';
|
||
import React, { useMemo, useRef } from '../../lib/teact/teact';
|
||
import { getActions, withGlobal } from '../../global';
|
||
|
||
import type {
|
||
ApiAudio, ApiChat, ApiMessage, ApiPeer,
|
||
} from '../../api/types';
|
||
import type { AudioOrigin } from '../../types';
|
||
|
||
import { PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION } from '../../config';
|
||
import {
|
||
getMediaDuration, getMessageContent, getMessageMediaHash, getSenderTitle, isMessageLocal,
|
||
} from '../../global/helpers';
|
||
import { selectChat, selectSender, selectTabState } from '../../global/selectors';
|
||
import { makeTrackId } from '../../util/audioPlayer';
|
||
import buildClassName from '../../util/buildClassName';
|
||
import * as mediaLoader from '../../util/mediaLoader';
|
||
import { clearMediaSession } from '../../util/mediaSession';
|
||
import { IS_IOS, IS_TOUCH_ENV } from '../../util/windowEnvironment';
|
||
import renderText from '../common/helpers/renderText';
|
||
|
||
import useAppLayout from '../../hooks/useAppLayout';
|
||
import useAudioPlayer from '../../hooks/useAudioPlayer';
|
||
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
|
||
import useLastCallback from '../../hooks/useLastCallback';
|
||
import useMessageMediaMetadata from '../../hooks/useMessageMediaMetadata';
|
||
import useOldLang from '../../hooks/useOldLang';
|
||
|
||
import Button from '../ui/Button';
|
||
import DropdownMenu from '../ui/DropdownMenu';
|
||
import MenuItem from '../ui/MenuItem';
|
||
import RangeSlider from '../ui/RangeSlider';
|
||
import RippleEffect from '../ui/RippleEffect';
|
||
|
||
import './AudioPlayer.scss';
|
||
|
||
type OwnProps = {
|
||
message: ApiMessage;
|
||
origin?: AudioOrigin;
|
||
className?: string;
|
||
noUi?: boolean;
|
||
};
|
||
|
||
type StateProps = {
|
||
sender?: ApiPeer;
|
||
chat?: ApiChat;
|
||
volume: number;
|
||
playbackRate: number;
|
||
isPlaybackRateActive?: boolean;
|
||
isMuted: boolean;
|
||
};
|
||
|
||
const PLAYBACK_RATES: Record<number, number> = {
|
||
0.5: 0.66,
|
||
0.75: 0.8,
|
||
1: 1,
|
||
1.5: 1.4,
|
||
2: 1.8,
|
||
};
|
||
const PLAYBACK_RATE_VALUES = Object.keys(PLAYBACK_RATES).sort().map(Number);
|
||
|
||
const REGULAR_PLAYBACK_RATE = 1;
|
||
const DEFAULT_FAST_PLAYBACK_RATE = 2;
|
||
|
||
const AudioPlayer: FC<OwnProps & StateProps> = ({
|
||
message,
|
||
className,
|
||
noUi,
|
||
sender,
|
||
chat,
|
||
volume,
|
||
playbackRate,
|
||
isPlaybackRateActive,
|
||
isMuted,
|
||
}) => {
|
||
const {
|
||
setAudioPlayerVolume,
|
||
setAudioPlayerPlaybackRate,
|
||
setAudioPlayerMuted,
|
||
focusMessage,
|
||
closeAudioPlayer,
|
||
} = getActions();
|
||
|
||
// eslint-disable-next-line no-null/no-null
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const lang = useOldLang();
|
||
const { isMobile } = useAppLayout();
|
||
const { audio, voice, video } = getMessageContent(message);
|
||
const isVoice = Boolean(voice || video);
|
||
const shouldRenderPlaybackButton = isVoice || (audio?.duration || 0) > PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION;
|
||
const senderName = sender ? getSenderTitle(lang, sender) : undefined;
|
||
const mediaData = mediaLoader.getFromMemory(getMessageMediaHash(message, 'inline')!) as (string | undefined);
|
||
const mediaMetadata = useMessageMediaMetadata(message, sender, chat);
|
||
|
||
const {
|
||
playPause,
|
||
stop,
|
||
isPlaying,
|
||
requestNextTrack,
|
||
requestPreviousTrack,
|
||
isFirst,
|
||
isLast,
|
||
setVolume,
|
||
toggleMuted,
|
||
setPlaybackRate,
|
||
} = useAudioPlayer(
|
||
makeTrackId(message),
|
||
getMediaDuration(message)!,
|
||
isVoice ? 'voice' : 'audio',
|
||
mediaData,
|
||
undefined,
|
||
mediaMetadata,
|
||
undefined,
|
||
true,
|
||
undefined,
|
||
undefined,
|
||
isMessageLocal(message),
|
||
true,
|
||
);
|
||
|
||
const {
|
||
isContextMenuOpen,
|
||
handleBeforeContextMenu, handleContextMenu,
|
||
handleContextMenuClose, handleContextMenuHide,
|
||
} = useContextMenuHandlers(ref);
|
||
|
||
const handleClick = useLastCallback(() => {
|
||
focusMessage({ chatId: message.chatId, messageId: message.id });
|
||
});
|
||
|
||
const handleClose = useLastCallback(() => {
|
||
if (!stop) {
|
||
return;
|
||
}
|
||
if (isPlaying) {
|
||
playPause();
|
||
}
|
||
closeAudioPlayer();
|
||
clearMediaSession();
|
||
stop();
|
||
});
|
||
|
||
const handleVolumeChange = useLastCallback((value: number) => {
|
||
if (!setVolume) {
|
||
return;
|
||
}
|
||
setAudioPlayerVolume({ volume: value / 100 });
|
||
setVolume(value / 100);
|
||
});
|
||
|
||
const handleVolumeClick = useLastCallback(() => {
|
||
if (IS_TOUCH_ENV && !IS_IOS) return;
|
||
if (!toggleMuted) {
|
||
return;
|
||
}
|
||
toggleMuted();
|
||
setAudioPlayerMuted({ isMuted: !isMuted });
|
||
});
|
||
|
||
const updatePlaybackRate = useLastCallback((newRate: number, isActive = true) => {
|
||
if (!setPlaybackRate) {
|
||
return;
|
||
}
|
||
const rate = PLAYBACK_RATES[newRate];
|
||
const shouldBeActive = newRate !== REGULAR_PLAYBACK_RATE && isActive;
|
||
setAudioPlayerPlaybackRate({ playbackRate: rate, isPlaybackRateActive: shouldBeActive });
|
||
setPlaybackRate(shouldBeActive ? rate : REGULAR_PLAYBACK_RATE);
|
||
});
|
||
|
||
const handlePlaybackClick = useLastCallback(() => {
|
||
handleContextMenuClose();
|
||
const oldRate = Number(Object.entries(PLAYBACK_RATES).find(([, rate]) => rate === playbackRate)?.[0])
|
||
|| REGULAR_PLAYBACK_RATE;
|
||
const newIsActive = !isPlaybackRateActive;
|
||
|
||
updatePlaybackRate(
|
||
newIsActive && oldRate === REGULAR_PLAYBACK_RATE ? DEFAULT_FAST_PLAYBACK_RATE : oldRate,
|
||
newIsActive,
|
||
);
|
||
});
|
||
|
||
const PlaybackRateButton = useLastCallback(() => {
|
||
const displayRate = Object.entries(PLAYBACK_RATES).find(([, rate]) => rate === playbackRate)?.[0]
|
||
|| REGULAR_PLAYBACK_RATE;
|
||
const text = `${playbackRate === REGULAR_PLAYBACK_RATE ? DEFAULT_FAST_PLAYBACK_RATE : displayRate}Х`;
|
||
return (
|
||
<div className="playback-wrapper">
|
||
{isContextMenuOpen && <div className="playback-backdrop" onClick={handleContextMenuClose} />}
|
||
|
||
<Button
|
||
round
|
||
className={buildClassName(
|
||
'playback-button', isPlaybackRateActive && 'applied', isContextMenuOpen && 'on-top',
|
||
)}
|
||
color="translucent"
|
||
size="smaller"
|
||
ariaLabel="Playback Rate"
|
||
ripple={!isMobile}
|
||
onMouseEnter={handleContextMenu}
|
||
onClick={handlePlaybackClick}
|
||
onMouseDown={handleBeforeContextMenu}
|
||
onContextMenu={handleContextMenu}
|
||
>
|
||
<span className={buildClassName(
|
||
'playback-button-inner',
|
||
text.length === 4 && 'small',
|
||
text.length === 5 && 'tiny',
|
||
)}
|
||
>
|
||
{text}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const volumeIcon = useMemo(() => {
|
||
if (volume === 0 || isMuted) return 'icon-muted';
|
||
if (volume < 0.3) return 'icon-volume-1';
|
||
if (volume < 0.6) return 'icon-volume-2';
|
||
return 'icon-volume-3';
|
||
}, [volume, isMuted]);
|
||
|
||
if (noUi) {
|
||
return undefined;
|
||
}
|
||
|
||
return (
|
||
<div className={buildClassName('AudioPlayer', className)} dir={lang.isRtl ? 'rtl' : undefined} ref={ref}>
|
||
<div className="AudioPlayer-content" onClick={handleClick}>
|
||
{audio ? renderAudio(audio) : renderVoice(lang('AttachAudio'), senderName)}
|
||
<RippleEffect />
|
||
</div>
|
||
|
||
<Button
|
||
round
|
||
ripple={!isMobile}
|
||
color="translucent"
|
||
size="smaller"
|
||
className="player-button"
|
||
disabled={isFirst?.()}
|
||
onClick={requestPreviousTrack}
|
||
ariaLabel="Previous track"
|
||
>
|
||
<i className="icon icon-skip-previous" />
|
||
</Button>
|
||
<Button
|
||
round
|
||
ripple={!isMobile}
|
||
color="translucent"
|
||
size="smaller"
|
||
className={buildClassName('toggle-play', 'player-button', isPlaying ? 'pause' : 'play')}
|
||
onClick={playPause}
|
||
ariaLabel={isPlaying ? 'Pause audio' : 'Play audio'}
|
||
>
|
||
<i className="icon icon-play" />
|
||
<i className="icon icon-pause" />
|
||
</Button>
|
||
<Button
|
||
round
|
||
ripple={!isMobile}
|
||
color="translucent"
|
||
size="smaller"
|
||
className="player-button"
|
||
disabled={isLast?.()}
|
||
onClick={requestNextTrack}
|
||
ariaLabel="Next track"
|
||
>
|
||
<i className="icon icon-skip-next" />
|
||
</Button>
|
||
|
||
<div className="volume-button-wrapper">
|
||
<Button
|
||
round
|
||
className="player-button volume-button"
|
||
color="translucent"
|
||
size="smaller"
|
||
ariaLabel="Volume"
|
||
onClick={handleVolumeClick}
|
||
ripple={!isMobile}
|
||
>
|
||
<i className={buildClassName('icon', volumeIcon)} />
|
||
</Button>
|
||
|
||
{!IS_IOS && (
|
||
<div className="volume-slider-wrapper">
|
||
<div className="volume-slider-spacer" />
|
||
<div className="volume-slider">
|
||
<RangeSlider bold value={isMuted ? 0 : volume * 100} onChange={handleVolumeChange} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{shouldRenderPlaybackButton && (
|
||
<DropdownMenu
|
||
forceOpen={isContextMenuOpen}
|
||
positionX="right"
|
||
positionY="top"
|
||
className="playback-rate-menu"
|
||
trigger={PlaybackRateButton}
|
||
onClose={handleContextMenuClose}
|
||
onHide={handleContextMenuHide}
|
||
onMouseEnterBackdrop={handleContextMenuClose}
|
||
>
|
||
{PLAYBACK_RATE_VALUES.map((rate) => {
|
||
return renderPlaybackRateMenuItem(rate, playbackRate, updatePlaybackRate, isPlaybackRateActive);
|
||
})}
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
<Button
|
||
round
|
||
className="player-close"
|
||
color="translucent"
|
||
size="smaller"
|
||
onClick={handleClose}
|
||
ariaLabel="Close player"
|
||
>
|
||
<i className="icon icon-close" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function renderAudio(audio: ApiAudio) {
|
||
const { title, performer, fileName } = audio;
|
||
|
||
return (
|
||
<>
|
||
<div className="title" dir="auto">{renderText(title || fileName)}</div>
|
||
{performer && (
|
||
<div className="subtitle" dir="auto">{renderText(performer)}</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function renderVoice(subtitle: string, senderName?: string) {
|
||
return (
|
||
<>
|
||
<div className="title" dir="auto">{senderName && renderText(senderName)}</div>
|
||
<div className="subtitle" dir="auto">{subtitle}</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function renderPlaybackRateMenuItem(
|
||
rate: number, currentRate: number, onClick: (rate: number) => void,
|
||
isPlaybackRateActive?: boolean,
|
||
) {
|
||
const isSelected = (currentRate === PLAYBACK_RATES[rate] && isPlaybackRateActive)
|
||
|| (rate === REGULAR_PLAYBACK_RATE && !isPlaybackRateActive);
|
||
return (
|
||
<MenuItem
|
||
key={rate}
|
||
// eslint-disable-next-line react/jsx-no-bind
|
||
onClick={() => onClick(rate)}
|
||
icon={isSelected ? 'check' : undefined}
|
||
customIcon={!isSelected ? <i className="icon icon-placeholder" /> : undefined}
|
||
>
|
||
{rate}X
|
||
</MenuItem>
|
||
);
|
||
}
|
||
|
||
export default withGlobal<OwnProps>(
|
||
(global, { message }): StateProps => {
|
||
const sender = selectSender(global, message);
|
||
const chat = selectChat(global, message.chatId);
|
||
const {
|
||
volume, playbackRate, isMuted, isPlaybackRateActive,
|
||
} = selectTabState(global).audioPlayer;
|
||
|
||
return {
|
||
sender,
|
||
chat,
|
||
volume,
|
||
playbackRate,
|
||
isPlaybackRateActive,
|
||
isMuted,
|
||
};
|
||
},
|
||
)(AudioPlayer);
|