Audio Player: Add track navigation and volume controls (#1526)

This commit is contained in:
Alexander Zinchuk 2021-11-05 21:57:14 +03:00
parent c604e6156d
commit 3d8bb54398
25 changed files with 545 additions and 207 deletions

BIN
src/assets/fonts/icomoon.woff Executable file → Normal file

Binary file not shown.

Binary file not shown.

View File

@ -58,7 +58,7 @@
--color-pinned: var(--color-white);
}
.icon-muted-chat {
.icon-muted {
color: var(--color-white) !important;
}
@ -89,7 +89,7 @@
max-width: 80%;
}
.icon-muted-chat {
.icon-muted {
font-size: 1.25rem;
margin-left: 0.25rem;
margin-top: -.0625rem;
@ -194,7 +194,7 @@
padding-right: 0;
}
.icon-muted-chat {
.icon-muted {
margin-left: 0;
margin-right: 0.25rem;
}

View File

@ -290,7 +290,7 @@ const Chat: FC<OwnProps & StateProps & DispatchProps> = ({
<div className="title">
<h3>{renderText(getChatTitle(lang, chat, privateChatUser))}</h3>
{chat.isVerified && <VerifiedIcon />}
{isMuted && <i className="icon-muted-chat" />}
{isMuted && <i className="icon-muted" />}
{chat.lastMessage && (
<LastMessageMeta
message={chat.lastMessage}

View File

@ -156,8 +156,8 @@ const SettingsGeneral: FC<OwnProps & StateProps & DispatchProps> = ({
<RangeSlider
label={lang('TextSize')}
// TODO Remove memo-killer
range={{ min: 12, max: 20 }}
min={12}
max={20}
value={messageTextSize}
onChange={handleMessageTextSizeChange}
/>

View File

@ -124,10 +124,8 @@ const SettingsNotifications: FC<OwnProps & StateProps & DispatchProps> = ({
<RangeSlider
label="Sound"
disabled={!hasWebNotifications}
range={{
min: 0,
max: 10,
}}
min={0}
max={10}
value={notificationSoundVolume}
onChange={(volume) => {
updateWebNotificationSettings({ notificationSoundVolume: volume });

View File

@ -13,7 +13,7 @@
margin: .125rem;
}
> .toggle-play {
> .player-button {
--color-text-secondary: var(--color-primary);
--color-text-secondary-rgb: var(--color-primary-shade-rgb);
--color-primary-shade: var(--color-green);
@ -24,7 +24,7 @@
margin-left: 1.5rem;
}
.toggle-play {
.player-button {
&.smaller {
width: 3rem;
height: 3rem;
@ -60,6 +60,55 @@
}
}
.volume-button {
position: relative;
overflow: visible;
.volume-slider-spacer {
position: absolute;
transform: translateY(100%);
bottom: 0;
height: 1rem;
width: 8rem;
cursor: default;
visibility: hidden;
}
&:hover .volume-slider-spacer, .volume-slider-spacer:hover {
visibility: visible;
}
.volume-slider {
opacity: 0;
visibility: hidden;
position: absolute;
background: var(--color-background);
bottom: -1rem;
transform: translateY(100%);
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
width: 8rem;
padding: 0.75rem;
border-radius: 0.5rem;
cursor: default;
box-shadow: 0 1px 2px var(--color-default-shadow);
.RangeSlider {
margin-bottom: 0;
input[type=range] {
margin-bottom: 0;
}
}
}
&:hover .volume-slider,
.volume-slider:hover,
.volume-slider-spacer:hover + .volume-slider {
opacity: 1;
visibility: visible;
}
}
&-content {
display: flex;
justify-content: center;
@ -69,7 +118,6 @@
position: relative;
overflow: hidden;
cursor: pointer;
max-width: 15rem;
border-radius: var(--border-radius-messages-small);
&:hover {

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback } from '../../lib/teact/teact';
import React, { FC, useCallback, useMemo } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { AudioOrigin } from '../../types';
@ -7,7 +7,7 @@ import {
ApiAudio, ApiChat, ApiMessage, ApiUser,
} from '../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import * as mediaLoader from '../../util/mediaLoader';
import {
getMediaDuration, getMessageContent, getMessageMediaHash, getSenderTitle,
@ -24,6 +24,7 @@ import { clearMediaSession } from '../../util/mediaSession';
import RippleEffect from '../ui/RippleEffect';
import Button from '../ui/Button';
import RangeSlider from '../ui/RangeSlider';
import './AudioPlayer.scss';
@ -37,12 +38,22 @@ type OwnProps = {
type StateProps = {
sender?: ApiChat | ApiUser;
chat?: ApiChat;
volume: number;
};
type DispatchProps = Pick<GlobalActions, 'focusMessage' | 'closeAudioPlayer'>;
type DispatchProps = Pick<GlobalActions, 'focusMessage' | 'closeAudioPlayer' | 'setAudioPlayerVolume'>;
const AudioPlayer: FC<OwnProps & StateProps & DispatchProps> = ({
message, origin = AudioOrigin.Inline, className, noUi, sender, focusMessage, closeAudioPlayer, chat,
message,
origin = AudioOrigin.Inline,
className,
noUi,
sender,
chat,
volume,
setAudioPlayerVolume,
focusMessage,
closeAudioPlayer,
}) => {
const lang = useLang();
const { audio, voice } = getMessageContent(message);
@ -50,7 +61,10 @@ const AudioPlayer: FC<OwnProps & StateProps & DispatchProps> = ({
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 } = useAudioPlayer(
const {
playPause, stop, isPlaying, requestNextTrack, requestPreviousTrack, isFirst, isLast, setVolume,
} = useAudioPlayer(
makeTrackId(message),
getMediaDuration(message)!,
isVoice ? 'voice' : 'audio',
@ -78,6 +92,18 @@ const AudioPlayer: FC<OwnProps & StateProps & DispatchProps> = ({
stop();
}, [closeAudioPlayer, isPlaying, playPause, stop]);
const handleVolumeChange = useCallback((value: number) => {
setAudioPlayerVolume({ volume: value / 100 });
setVolume(value / 100);
}, [setAudioPlayerVolume, setVolume]);
const volumeIcon = useMemo(() => {
if (volume === 0) return 'icon-muted';
if (volume < 0.3) return 'icon-volume-1';
if (volume < 0.6) return 'icon-volume-2';
return 'icon-volume-3';
}, [volume]);
if (noUi) {
return undefined;
}
@ -89,19 +115,59 @@ const AudioPlayer: FC<OwnProps & StateProps & DispatchProps> = ({
ripple={!IS_SINGLE_COLUMN_LAYOUT}
color="translucent"
size="smaller"
className={buildClassName('toggle-play', isPlaying ? 'pause' : 'play')}
className="player-button"
disabled={isFirst}
onClick={requestPreviousTrack}
ariaLabel="Previous track"
>
<i className="icon-skip-previous" />
</Button>
<Button
round
ripple={!IS_SINGLE_COLUMN_LAYOUT}
color="translucent"
size="smaller"
className={buildClassName('toggle-play', 'player-button', isPlaying ? 'pause' : 'play')}
onClick={playPause}
ariaLabel={isPlaying ? 'Pause audio' : 'Play audio'}
>
<i className="icon-play" />
<i className="icon-pause" />
</Button>
<Button
round
ripple={!IS_SINGLE_COLUMN_LAYOUT}
color="translucent"
size="smaller"
className="player-button"
disabled={isLast}
onClick={requestNextTrack}
ariaLabel="Next track"
>
<i className="icon-skip-next" />
</Button>
<div className="AudioPlayer-content" onClick={handleClick}>
{audio ? renderAudio(audio) : renderVoice(lang('AttachAudio'), senderName)}
<RippleEffect />
</div>
{!IS_IOS && (
<Button
round
className="player-button volume-button"
color="translucent"
size="smaller"
ariaLabel="Volume"
withClickPropagation
>
<i className={volumeIcon} />
<div className="volume-slider-spacer" />
<div className="volume-slider">
<RangeSlider value={volume * 100} onChange={handleVolumeChange} />
</div>
</Button>
)}
<Button
round
className="player-close"
@ -142,11 +208,13 @@ export default withGlobal<OwnProps>(
(global, { message }): StateProps => {
const sender = selectSender(global, message);
const chat = selectChat(global, message.chatId);
const { volume } = global.audioPlayer;
return {
sender,
chat,
volume,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['focusMessage', 'closeAudioPlayer']),
(setGlobal, actions): DispatchProps => pick(actions, ['focusMessage', 'closeAudioPlayer', 'setAudioPlayerVolume']),
)(AudioPlayer);

View File

@ -8,7 +8,6 @@
left: 0;
right: 0;
height: 2.875rem;
overflow: hidden;
box-shadow: 0 2px 2px var(--color-light-shadow);
display: flex;

View File

@ -248,7 +248,7 @@ const RoundVideo: FC<OwnProps> = ({
)}
<div className="message-media-duration">
{isActivated ? formatMediaDuration(playerRef.current!.currentTime) : formatMediaDuration(video.duration)}
{(!isActivated || playerRef.current!.paused) && <i className="icon-muted-chat" />}
{(!isActivated || playerRef.current!.paused) && <i className="icon-muted" />}
</div>
</div>
);

View File

@ -516,7 +516,7 @@
user-select: none;
}
.message-media-duration .icon-muted-chat {
.message-media-duration .icon-muted {
vertical-align: -.1875rem;
margin-left: .375rem;
font-size: 1.0625rem;

View File

@ -32,6 +32,7 @@ export type OwnProps = {
faded?: boolean;
tabIndex?: number;
isRtl?: boolean;
withClickPropagation?: boolean;
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
@ -70,6 +71,7 @@ const Button: FC<OwnProps> = ({
faded,
tabIndex,
isRtl,
withClickPropagation,
}) => {
// eslint-disable-next-line no-null/no-null
let elementRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
@ -108,11 +110,11 @@ const Button: FC<OwnProps> = ({
}, [disabled, onClick]);
const handleMouseDown = useCallback((e: ReactMouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!withClickPropagation) e.preventDefault();
if (!disabled && onMouseDown) {
onMouseDown(e);
}
}, [onMouseDown, disabled]);
}, [onMouseDown, disabled, withClickPropagation]);
if (href) {
return (

View File

@ -100,6 +100,14 @@
&::-moz-slider-thumb {
-moz-appearance: none;
}
&::-webkit-slider-runnable-track {
cursor: pointer;
}
&::-moz-range-track, &::-moz-range-progress {
cursor: pointer;
}
}
// Apply custom styles

View File

@ -10,7 +10,9 @@ import './RangeSlider.scss';
type OwnProps = {
options?: string[];
range?: { min: number; max: number; step?: number };
min?: number;
max?: number;
step?: number;
label?: string;
value: number;
disabled?: boolean;
@ -19,7 +21,9 @@ type OwnProps = {
const RangeSlider: FC<OwnProps> = ({
options,
range,
min = 0,
max = options ? options.length - 1 : 100,
step = 1,
label,
value,
disabled,
@ -38,29 +42,19 @@ const RangeSlider: FC<OwnProps> = ({
const trackWidth = useMemo(() => {
if (options) {
return (value / (options.length - 1)) * 100;
} else if (range) {
const possibleValuesLength = (range.max - range.min) / (range.step || 1);
return ((value - range.min) / possibleValuesLength) * 100;
} else {
const possibleValuesLength = (max - min) / step;
return ((value - min) / possibleValuesLength) * 100;
}
return 0;
}, [value, options, range]);
const [min, max, step] = useMemo(() => {
if (options) {
return [0, options.length - 1, 1];
} else if (range) {
return [range.min, range.max, range.step || 1];
}
return [0, 0, 0];
}, [range, options]);
}, [options, value, max, min, step]);
return (
<div className={className}>
{label && (
<div className="slider-top-row" dir={lang.isRtl ? 'rtl' : undefined}>
<span className="label" dir="auto">{label}</span>
{range && (
{!options && (
<span className="value" dir="auto">{value}</span>
)}
</div>

View File

@ -60,6 +60,8 @@ export const PROFILE_SENSITIVE_AREA = 500;
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;
export const ALL_CHATS_PRELOAD_DISABLED = false;
export const DEFAULT_VOLUME = 1;
export const ANIMATION_LEVEL_MIN = 0;
export const ANIMATION_LEVEL_MED = 1;
export const ANIMATION_LEVEL_MAX = 2;

View File

@ -13,6 +13,7 @@ import {
GLOBAL_STATE_CACHE_CHAT_LIST_LIMIT,
MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN,
GLOBAL_STATE_CACHE_USER_LIST_LIMIT,
DEFAULT_VOLUME,
} from '../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../util/environment';
import { ANIMATION_END_EVENT, ANIMATION_START_EVENT } from '../hooks/useHeavyAnimationCheck';
@ -132,6 +133,10 @@ function readCache(initialState: GlobalState): GlobalState {
if (!cached.serviceNotifications) {
cached.serviceNotifications = [];
}
if (cached.audioPlayer.volume === undefined) {
cached.audioPlayer.volume = DEFAULT_VOLUME;
}
}
const newState = {
@ -178,6 +183,9 @@ function updateCache() {
'leftColumnWidth',
'serviceNotifications',
]),
audioPlayer: {
volume: global.audioPlayer.volume,
},
isChatInfoShown: reduceShowChatInfo(global),
users: reduceUsers(global),
chats: reduceChats(global),

View File

@ -3,6 +3,7 @@ import { NewChatMembersProgress } from '../types';
import {
ANIMATION_LEVEL_DEFAULT, DARK_THEME_PATTERN_COLOR, DEFAULT_MESSAGE_TEXT_SIZE_PX, DEFAULT_PATTERN_COLOR,
DEFAULT_VOLUME,
IOS_DEFAULT_MESSAGE_TEXT_SIZE_PX, MACOS_DEFAULT_MESSAGE_TEXT_SIZE_PX,
} from '../config';
import { IS_IOS, IS_MAC_OS } from '../util/environment';
@ -110,7 +111,9 @@ export const INITIAL_STATE: GlobalState = {
mediaViewer: {},
audioPlayer: {},
audioPlayer: {
volume: DEFAULT_VOLUME,
},
forwardMessages: {},

View File

@ -328,6 +328,7 @@ export type GlobalState = {
messageId?: number;
threadId?: number;
origin?: AudioOrigin;
volume: number;
};
topPeers: {
@ -523,9 +524,10 @@ export type ActionTypes = (
'clickInlineButton' | 'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' |
'resetInlineBot' | 'restartBot' | 'startBot' |
// misc
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |
'deleteDeviceToken' | 'checkVersionNotification' | 'createServiceNotification' |
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'setAudioPlayerVolume' | 'closeAudioPlayer' |
'openPollModal' | 'closePollModal' | 'loadWebPagePreview' | 'clearWebPagePreview' |
'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' | 'deleteDeviceToken' |
'checkVersionNotification' | 'createServiceNotification' |
// payment
'openPaymentModal' | 'closePaymentModal' | 'addPaymentError' |
'validateRequestedInfo' | 'setPaymentStep' | 'sendPaymentForm' | 'getPaymentForm' | 'getReceipt' |

View File

@ -1,7 +1,7 @@
import {
useCallback, useEffect, useRef, useState,
} from '../lib/teact/teact';
import { getDispatch } from '../lib/teact/teactn';
import { getDispatch, getGlobal } from '../lib/teact/teactn';
import { AudioOrigin } from '../types';
@ -48,12 +48,21 @@ export default (
useOnChange(() => {
controllerRef.current = register(trackId, trackType, origin, (eventName, e) => {
switch (eventName) {
case 'onPlay':
case 'onPlay': {
const { setVolume, proxy } = controllerRef.current!;
setIsPlaying(true);
registerMediaSession(metadata, makeMediaHandlers(controllerRef));
setPlaybackState('playing');
setVolume(getGlobal().audioPlayer.volume);
setPositionState({
duration: proxy.duration || 0,
playbackRate: proxy.playbackRate,
position: proxy.currentTime,
});
break;
}
case 'onPause':
setIsPlaying(false);
setPlaybackState('paused');
@ -62,11 +71,6 @@ export default (
const { proxy } = controllerRef.current!;
const duration = proxy.duration && Number.isFinite(proxy.duration) ? proxy.duration : originalDuration;
if (!noProgressUpdates) setPlayProgress(proxy.currentTime / duration);
setPositionState({
duration: proxy.duration,
playbackRate: proxy.playbackRate,
position: proxy.currentTime,
});
break;
}
case 'onEnded': {
@ -95,7 +99,18 @@ export default (
}, [metadata, isPlaying]);
const {
play, pause, setCurrentTime, proxy, destroy, setVolume, setCurrentOrigin, stop,
play,
pause,
setCurrentTime,
proxy,
destroy,
setVolume,
setCurrentOrigin,
stop,
isFirst,
isLast,
requestNextTrack,
requestPreviousTrack,
} = controllerRef.current!;
const duration = proxy.duration && Number.isFinite(proxy.duration) ? proxy.duration : originalDuration;
@ -160,6 +175,10 @@ export default (
setVolume,
audioProxy: proxy,
duration,
requestNextTrack,
requestPreviousTrack,
isFirst: isFirst(),
isLast: isLast(),
};
};

View File

@ -32,7 +32,9 @@ export default (message: ApiMessage, sender?: ApiUser | ApiChat, chat?: ApiChat)
const hash = (audio && audioCoverHash) || (voice && avatarHash);
const media = useMedia(hash);
const size = getCoverSize(audio, voice, media);
const size = useMemo(() => {
return getCoverSize(audio, voice, media);
}, [audio, media, voice]);
const { result: url } = useAsync(() => makeGoodArtwork(media, size), [media, size], telegramLogoPath);
return useMemo(() => {
return buildMediaMetadata({

View File

@ -170,7 +170,7 @@ addReducer('closeMediaViewer', (global) => {
addReducer('openAudioPlayer', (global, actions, payload) => {
const {
chatId, threadId, messageId, origin,
chatId, threadId, messageId, origin, volume,
} = payload!;
return {
@ -180,6 +180,21 @@ addReducer('openAudioPlayer', (global, actions, payload) => {
threadId,
messageId,
origin,
volume: volume || global.audioPlayer.volume,
},
};
});
addReducer('setAudioPlayerVolume', (global, actions, payload) => {
const {
volume,
} = payload!;
return {
...global,
audioPlayer: {
...global.audioPlayer,
volume,
},
};
});
@ -187,7 +202,9 @@ addReducer('openAudioPlayer', (global, actions, payload) => {
addReducer('closeAudioPlayer', (global) => {
return {
...global,
audioPlayer: {},
audioPlayer: {
volume: global.audioPlayer.volume, // Preserve only volume for the next play
},
};
});

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,24 @@
}
}
.icon-loop:before {
content: "\e981";
}
.icon-skip-next:before {
content: "\e982";
}
.icon-skip-previous:before {
content: "\e983";
}
.icon-volume-1:before {
content: "\e984";
}
.icon-volume-2:before {
content: "\e985";
}
.icon-volume-3:before {
content: "\e986";
}
.icon-bot-commands-filled:before {
content: "\e97f";
}
@ -404,7 +422,7 @@
.icon-eye:before {
content: "\e924";
}
.icon-muted-chat:before {
.icon-muted:before {
content: "\e95d";
}
.icon-avatar-archived-chats:before {

View File

@ -48,7 +48,7 @@ export function updateMetadata(metadata?: MediaMetadata) {
const { mediaSession } = window.navigator;
if (mediaSession) {
// eslint-disable-next-line no-null/no-null
mediaSession.metadata = metadata !== undefined ? metadata : null;
mediaSession.metadata = metadata ?? null;
}
}
@ -73,7 +73,7 @@ export function clearMediaSession() {
mediaSession.metadata = null;
setMediaSessionHandlers(DEFAULT_HANDLERS);
if (mediaSession.playbackState) mediaSession.playbackState = 'none';
if (mediaSession.setPositionState) mediaSession.setPositionState(undefined);
mediaSession.setPositionState?.();
}
}
@ -85,29 +85,23 @@ export function setPlaybackState(state: 'none' | 'paused' | 'playing' = 'none')
}
export function setPositionState(state?: MediaPositionState) {
if (!state || !state.position || !state.duration) return;
if (!state || state.position === undefined || state.duration === undefined) return;
state.position = Math.min(state.position, state.duration);
const { mediaSession } = window.navigator;
if (mediaSession && mediaSession.setPositionState) {
mediaSession.setPositionState(state);
}
mediaSession?.setPositionState?.(state);
}
export function setMicrophoneActive(active: boolean) {
const { mediaSession } = window.navigator;
// @ts-ignore typings not updated yet
if (mediaSession && mediaSession.setMicrophoneActive) {
// @ts-ignore
mediaSession.setMicrophoneActive(active);
}
mediaSession?.setMicrophoneActive?.(active);
}
export function setCameraActive(active: boolean) {
const { mediaSession } = window.navigator;
// @ts-ignore typings not updated yet
if (mediaSession && mediaSession.setCameraActive) {
// @ts-ignore
mediaSession.setCameraActive(active);
}
mediaSession?.setCameraActive?.(active);
}
export function buildMediaMetadata({

View File

@ -4,7 +4,7 @@ export default (mediaEl: HTMLMediaElement) => {
mediaEl.play().catch((err) => {
if (DEBUG) {
// eslint-disable-next-line no-console
console.warn(err);
console.warn(err, mediaEl);
}
});
};