Audio Player: Various fixes (#2376)

This commit is contained in:
Alexander Zinchuk 2023-01-28 02:16:41 +01:00
parent 1c2a9a4a3c
commit bf4e2ed994
8 changed files with 141 additions and 54 deletions

View File

@ -13,14 +13,14 @@
margin: 0.125rem; margin: 0.125rem;
} }
> .player-button { .player-button {
--color-text-secondary: var(--color-primary); --color-text-secondary: var(--color-primary);
--color-text-secondary-rgb: var(--color-primary-shade-rgb); --color-text-secondary-rgb: var(--color-primary-shade-rgb);
--color-primary-shade: var(--color-green); --color-primary-shade: var(--color-green);
--color-white: var(--color-background-own); --color-white: var(--color-background-own);
}
.player-button { margin: 0.125rem;
&.smaller i { &.smaller i {
font-size: 1.625rem; font-size: 1.625rem;
margin-top: -0.0625rem; margin-top: -0.0625rem;
@ -52,9 +52,17 @@
} }
} }
.volume-button { .volume-button-wrapper {
position: relative; position: relative;
overflow: visible; display: flex;
flex-direction: column;
align-items: center;
.volume-slider-wrapper {
position: absolute;
width: 8rem;
top: 2.625rem;
}
.volume-slider-spacer { .volume-slider-spacer {
position: absolute; position: absolute;
@ -102,21 +110,43 @@
} }
} }
.playback-wrapper {
position: relative;
}
.playback-backdrop {
position: absolute;
top: 0;
right: 0;
width: 5rem;
height: 4rem;
z-index: calc(var(--z-menu-backdrop) + 1);
}
.playback-button { .playback-button {
overflow: visible; &.on-top {
z-index: calc(var(--z-menu-backdrop) + 2);
}
&.applied { &.applied {
--color-text-secondary: var(--color-primary); --color-text-secondary: var(--color-primary);
} }
.playback-button-inner { .playback-button-inner {
transition: 0.2s color ease-in-out; transition: 0.15s color ease-out;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: bold; font-weight: bold;
border: 2px solid; border: 2px solid;
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
&.small {
transform: scale(0.83);
}
&.tiny {
transform: scale(0.75);
}
} }
} }

View File

@ -46,6 +46,7 @@ type StateProps = {
chat?: ApiChat; chat?: ApiChat;
volume: number; volume: number;
playbackRate: number; playbackRate: number;
isPlaybackRateActive?: boolean;
isMuted: boolean; isMuted: boolean;
}; };
@ -56,6 +57,10 @@ const PLAYBACK_RATES: Record<number, number> = {
1.5: 1.4, 1.5: 1.4,
2: 1.8, 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> = ({ const AudioPlayer: FC<OwnProps & StateProps> = ({
message, message,
@ -65,6 +70,7 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
chat, chat,
volume, volume,
playbackRate, playbackRate,
isPlaybackRateActive,
isMuted, isMuted,
}) => { }) => {
const { const {
@ -143,37 +149,62 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
setAudioPlayerMuted({ isMuted: !isMuted }); setAudioPlayerMuted({ isMuted: !isMuted });
}, [isMuted, setAudioPlayerMuted, toggleMuted]); }, [isMuted, setAudioPlayerMuted, toggleMuted]);
const updatePlaybackRate = useCallback((newRate: number) => { const updatePlaybackRate = useCallback((newRate: number, isActive = true) => {
const rate = PLAYBACK_RATES[newRate]; const rate = PLAYBACK_RATES[newRate];
setAudioPlayerPlaybackRate({ playbackRate: rate }); const shouldBeActive = newRate !== REGULAR_PLAYBACK_RATE && isActive;
setPlaybackRate(rate); setAudioPlayerPlaybackRate({ playbackRate: rate, isPlaybackRateActive: shouldBeActive });
setPlaybackRate(shouldBeActive ? rate : REGULAR_PLAYBACK_RATE);
}, [setAudioPlayerPlaybackRate, setPlaybackRate]); }, [setAudioPlayerPlaybackRate, setPlaybackRate]);
const handlePlaybackClick = useCallback(() => { const handlePlaybackClick = useCallback(() => {
if (isContextMenuOpen) return; handleContextMenuClose();
updatePlaybackRate(playbackRate === 1 ? 2 : 1); const oldRate = Number(Object.entries(PLAYBACK_RATES).find(([, rate]) => rate === playbackRate)?.[0])
}, [isContextMenuOpen, playbackRate, updatePlaybackRate]); || REGULAR_PLAYBACK_RATE;
const newIsActive = !isPlaybackRateActive;
updatePlaybackRate(
newIsActive && oldRate === REGULAR_PLAYBACK_RATE ? DEFAULT_FAST_PLAYBACK_RATE : oldRate,
newIsActive,
);
}, [handleContextMenuClose, isPlaybackRateActive, playbackRate, updatePlaybackRate]);
const PlaybackRateButton = useCallback(() => { const PlaybackRateButton = useCallback(() => {
const displayRate = Object.entries(PLAYBACK_RATES).find(([, rate]) => rate === playbackRate)?.[0] || 1; 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 ( return (
<Button <div className="playback-wrapper">
round {isContextMenuOpen && <div className="playback-backdrop" onClick={handleContextMenuClose} />}
className={buildClassName('playback-button', playbackRate !== 1 && 'applied')}
color="translucent" <Button
size="smaller" round
ariaLabel="Playback Rate" className={buildClassName(
ripple={!isMobile} 'playback-button', isPlaybackRateActive && 'applied', isContextMenuOpen && 'on-top',
onClick={handlePlaybackClick} )}
onMouseDown={handleBeforeContextMenu} color="translucent"
onContextMenu={handleContextMenu} size="smaller"
> ariaLabel="Playback Rate"
<span className="playback-button-inner"> ripple={!isMobile}
{playbackRate === 1 ? 2 : displayRate}Х onMouseEnter={handleContextMenu}
</span> onClick={handlePlaybackClick}
</Button> onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
>
<span className={buildClassName(
'playback-button-inner',
text.length === 4 && 'small',
text.length === 5 && 'tiny',
)}
>
{text}
</span>
</Button>
</div>
); );
}, [handleBeforeContextMenu, handleContextMenu, handlePlaybackClick, isMobile, playbackRate]); }, [
handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handlePlaybackClick, isContextMenuOpen,
isMobile, isPlaybackRateActive, playbackRate,
]);
const volumeIcon = useMemo(() => { const volumeIcon = useMemo(() => {
if (volume === 0 || isMuted) return 'icon-muted'; if (volume === 0 || isMuted) return 'icon-muted';
@ -230,24 +261,28 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
<i className="icon-skip-next" /> <i className="icon-skip-next" />
</Button> </Button>
<Button <div className="volume-button-wrapper">
round <Button
className="player-button volume-button" round
color="translucent" className="player-button volume-button"
size="smaller" color="translucent"
ariaLabel="Volume" size="smaller"
noPreventDefault ariaLabel="Volume"
> onClick={handleVolumeClick}
<i className={volumeIcon} onClick={handleVolumeClick} /> ripple={!isMobile}
>
<i className={volumeIcon} />
</Button>
{!IS_IOS && ( {!IS_IOS && (
<> <div className="volume-slider-wrapper">
<div className="volume-slider-spacer" /> <div className="volume-slider-spacer" />
<div className="volume-slider"> <div className="volume-slider">
<RangeSlider bold value={isMuted ? 0 : volume * 100} onChange={handleVolumeChange} /> <RangeSlider bold value={isMuted ? 0 : volume * 100} onChange={handleVolumeChange} />
</div> </div>
</> </div>
)} )}
</Button> </div>
{shouldRenderPlaybackButton && ( {shouldRenderPlaybackButton && (
<DropdownMenu <DropdownMenu
@ -258,12 +293,11 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
trigger={PlaybackRateButton} trigger={PlaybackRateButton}
onClose={handleContextMenuClose} onClose={handleContextMenuClose}
onHide={handleContextMenuHide} onHide={handleContextMenuHide}
onMouseEnterBackdrop={handleContextMenuClose}
> >
{renderPlaybackRateMenuItem(0.5, playbackRate, updatePlaybackRate)} {PLAYBACK_RATE_VALUES.map((rate) => {
{renderPlaybackRateMenuItem(0.75, playbackRate, updatePlaybackRate)} return renderPlaybackRateMenuItem(rate, playbackRate, updatePlaybackRate, isPlaybackRateActive);
{renderPlaybackRateMenuItem(1, playbackRate, updatePlaybackRate)} })}
{renderPlaybackRateMenuItem(1.5, playbackRate, updatePlaybackRate)}
{renderPlaybackRateMenuItem(2, playbackRate, updatePlaybackRate)}
</DropdownMenu> </DropdownMenu>
)} )}
@ -303,13 +337,19 @@ function renderVoice(subtitle: string, senderName?: string) {
); );
} }
function renderPlaybackRateMenuItem(rate: number, currentRate: number, onClick: (rate: number) => void) { 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 ( return (
<MenuItem <MenuItem
key={rate}
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
onClick={() => onClick(rate)} onClick={() => onClick(rate)}
icon={currentRate === PLAYBACK_RATES[rate] ? 'check' : undefined} icon={isSelected ? 'check' : undefined}
customIcon={currentRate !== PLAYBACK_RATES[rate] ? <i className="icon-placeholder" /> : undefined} customIcon={!isSelected ? <i className="icon-placeholder" /> : undefined}
> >
{rate}X {rate}X
</MenuItem> </MenuItem>
@ -320,13 +360,16 @@ export default withGlobal<OwnProps>(
(global, { message }): StateProps => { (global, { message }): StateProps => {
const sender = selectSender(global, message); const sender = selectSender(global, message);
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
const { volume, playbackRate, isMuted } = selectTabState(global).audioPlayer; const {
volume, playbackRate, isMuted, isPlaybackRateActive,
} = selectTabState(global).audioPlayer;
return { return {
sender, sender,
chat, chat,
volume, volume,
playbackRate, playbackRate,
isPlaybackRateActive,
isMuted, isMuted,
}; };
}, },

View File

@ -46,7 +46,7 @@ export type OwnProps = {
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void; onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void; onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
onMouseEnter?: NoneToVoidFunction; onMouseEnter?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
onMouseLeave?: NoneToVoidFunction; onMouseLeave?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction; onFocus?: NoneToVoidFunction;
onTransitionEnd?: NoneToVoidFunction; onTransitionEnd?: NoneToVoidFunction;

View File

@ -16,6 +16,7 @@ type OwnProps = {
onClose?: NoneToVoidFunction; onClose?: NoneToVoidFunction;
onHide?: NoneToVoidFunction; onHide?: NoneToVoidFunction;
onTransitionEnd?: NoneToVoidFunction; onTransitionEnd?: NoneToVoidFunction;
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
children: React.ReactNode; children: React.ReactNode;
}; };
@ -30,6 +31,7 @@ const DropdownMenu: FC<OwnProps> = ({
onOpen, onOpen,
onClose, onClose,
onTransitionEnd, onTransitionEnd,
onMouseEnterBackdrop,
onHide, onHide,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -88,6 +90,7 @@ const DropdownMenu: FC<OwnProps> = ({
onClose={handleClose} onClose={handleClose}
shouldSkipTransition={forceOpen} shouldSkipTransition={forceOpen}
onCloseAnimationEnd={onHide} onCloseAnimationEnd={onHide}
onMouseEnterBackdrop={onMouseEnterBackdrop}
> >
{children} {children}
</Menu> </Menu>

View File

@ -40,6 +40,7 @@ type OwnProps = {
onCloseAnimationEnd?: () => void; onCloseAnimationEnd?: () => void;
onClose: () => void; onClose: () => void;
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onMouseEnter?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseEnterBackdrop?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onMouseLeave?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
withPortal?: boolean; withPortal?: boolean;
children: React.ReactNode; children: React.ReactNode;
@ -71,6 +72,7 @@ const Menu: FC<OwnProps> = ({
onMouseLeave, onMouseLeave,
shouldSkipTransition, shouldSkipTransition,
withPortal, withPortal,
onMouseEnterBackdrop,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
let menuRef = useRef<HTMLDivElement>(null); let menuRef = useRef<HTMLDivElement>(null);
@ -143,7 +145,11 @@ const Menu: FC<OwnProps> = ({
> >
{isOpen && ( {isOpen && (
// This only prevents click events triggering on underlying elements // This only prevents click events triggering on underlying elements
<div className="backdrop" onMouseDown={preventMessageInputBlurWithBubbling} /> <div
className="backdrop"
onMouseDown={preventMessageInputBlurWithBubbling}
onMouseEnter={onMouseEnterBackdrop}
/>
)} )}
<div <div
role="presentation" role="presentation"

View File

@ -44,5 +44,6 @@
border-radius: 50%; border-radius: 50%;
transform: scale(0); transform: scale(0);
animation: ripple-animation 700ms; animation: ripple-animation 700ms;
pointer-events: none;
} }
} }

View File

@ -212,13 +212,14 @@ addActionHandler('setAudioPlayerVolume', (global, actions, payload): ActionRetur
addActionHandler('setAudioPlayerPlaybackRate', (global, actions, payload): ActionReturnType => { addActionHandler('setAudioPlayerPlaybackRate', (global, actions, payload): ActionReturnType => {
const { const {
playbackRate, tabId = getCurrentTabId(), playbackRate, isPlaybackRateActive, tabId = getCurrentTabId(),
} = payload; } = payload;
return updateTabState(global, { return updateTabState(global, {
audioPlayer: { audioPlayer: {
...selectTabState(global, tabId).audioPlayer, ...selectTabState(global, tabId).audioPlayer,
playbackRate, playbackRate,
isPlaybackRateActive,
}, },
}, tabId); }, tabId);
}); });
@ -256,6 +257,7 @@ addActionHandler('closeAudioPlayer', (global, actions, payload): ActionReturnTyp
audioPlayer: { audioPlayer: {
volume: tabState.audioPlayer.volume, volume: tabState.audioPlayer.volume,
playbackRate: tabState.audioPlayer.playbackRate, playbackRate: tabState.audioPlayer.playbackRate,
isPlaybackRateActive: undefined,
isMuted: tabState.audioPlayer.isMuted, isMuted: tabState.audioPlayer.isMuted,
}, },
}, tabId); }, tabId);

View File

@ -321,6 +321,7 @@ export type TabState = {
origin?: AudioOrigin; origin?: AudioOrigin;
volume: number; volume: number;
playbackRate: number; playbackRate: number;
isPlaybackRateActive?: boolean;
isMuted: boolean; isMuted: boolean;
}; };
@ -1708,6 +1709,7 @@ export interface ActionPayloads {
} & WithTabId; } & WithTabId;
setAudioPlayerPlaybackRate: { setAudioPlayerPlaybackRate: {
playbackRate: number; playbackRate: number;
isPlaybackRateActive?: boolean;
} & WithTabId; } & WithTabId;
setAudioPlayerMuted: { setAudioPlayerMuted: {
isMuted: boolean; isMuted: boolean;