844 lines
23 KiB
TypeScript
844 lines
23 KiB
TypeScript
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
|
|
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|
|
|
import classNames from 'classnames';
|
|
|
|
import { useSpring, animated, config } from '@react-spring/web';
|
|
|
|
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
|
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
|
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
|
|
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
|
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
|
|
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
|
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
|
import { Blurhash } from 'mastodon/components/blurhash';
|
|
import { Icon } from 'mastodon/components/icon';
|
|
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
|
import { formatTime, getPointerPosition } from 'mastodon/features/video';
|
|
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
|
import {
|
|
displayMedia,
|
|
useBlurhash,
|
|
reduceMotion,
|
|
} from 'mastodon/initial_state';
|
|
import { playerSettings } from 'mastodon/settings';
|
|
|
|
const messages = defineMessages({
|
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
|
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
|
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
|
download: { id: 'video.download', defaultMessage: 'Download file' },
|
|
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
|
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
|
|
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
|
|
});
|
|
|
|
const persistVolume = (volume: number, muted: boolean) => {
|
|
playerSettings.set('volume', volume);
|
|
playerSettings.set('muted', muted);
|
|
};
|
|
|
|
const restoreVolume = (audio: HTMLAudioElement) => {
|
|
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
|
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
|
|
|
audio.volume = volume;
|
|
audio.muted = muted;
|
|
};
|
|
|
|
const HOVER_FADE_DELAY = 4000;
|
|
|
|
export const Audio: React.FC<{
|
|
src: string;
|
|
alt?: string;
|
|
lang?: string;
|
|
poster?: string;
|
|
sensitive?: boolean;
|
|
editable?: boolean;
|
|
blurhash?: string;
|
|
visible?: boolean;
|
|
duration?: number;
|
|
onToggleVisibility?: () => void;
|
|
backgroundColor?: string;
|
|
foregroundColor?: string;
|
|
accentColor?: string;
|
|
startTime?: number;
|
|
startPlaying?: boolean;
|
|
startVolume?: number;
|
|
startMuted?: boolean;
|
|
deployPictureInPicture?: (
|
|
type: string,
|
|
mediaProps: {
|
|
src: string;
|
|
muted: boolean;
|
|
volume: number;
|
|
currentTime: number;
|
|
poster?: string;
|
|
backgroundColor: string;
|
|
foregroundColor: string;
|
|
accentColor: string;
|
|
},
|
|
) => void;
|
|
matchedFilters?: string[];
|
|
}> = ({
|
|
src,
|
|
alt,
|
|
lang,
|
|
poster,
|
|
duration,
|
|
sensitive,
|
|
editable,
|
|
blurhash,
|
|
visible,
|
|
onToggleVisibility,
|
|
backgroundColor = '#000000',
|
|
foregroundColor = '#ffffff',
|
|
accentColor = '#ffffff',
|
|
startTime,
|
|
startPlaying,
|
|
startVolume,
|
|
startMuted,
|
|
deployPictureInPicture,
|
|
matchedFilters,
|
|
}) => {
|
|
const intl = useIntl();
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [loadedDuration, setDuration] = useState(duration ?? 0);
|
|
const [paused, setPaused] = useState(true);
|
|
const [muted, setMuted] = useState(false);
|
|
const [volume, setVolume] = useState(0.5);
|
|
const [hovered, setHovered] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [revealed, setRevealed] = useState(false);
|
|
|
|
const playerRef = useRef<HTMLDivElement>(null);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const seekRef = useRef<HTMLDivElement>(null);
|
|
const volumeRef = useRef<HTMLDivElement>(null);
|
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
|
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
|
audioRef,
|
|
3,
|
|
);
|
|
const accessibilityId = useId();
|
|
|
|
const [style, spring] = useSpring(() => ({
|
|
progress: '0%',
|
|
buffer: '0%',
|
|
volume: '0%',
|
|
}));
|
|
|
|
const handleAudioRef = useCallback(
|
|
(c: HTMLVideoElement | null) => {
|
|
if (audioRef.current && !audioRef.current.paused && c === null) {
|
|
deployPictureInPicture?.('audio', {
|
|
src,
|
|
poster,
|
|
backgroundColor,
|
|
foregroundColor,
|
|
accentColor,
|
|
currentTime: audioRef.current.currentTime,
|
|
muted: audioRef.current.muted,
|
|
volume: audioRef.current.volume,
|
|
});
|
|
}
|
|
|
|
audioRef.current = c;
|
|
|
|
if (audioRef.current) {
|
|
restoreVolume(audioRef.current);
|
|
setVolume(audioRef.current.volume);
|
|
setMuted(audioRef.current.muted);
|
|
void spring.start({
|
|
volume: `${audioRef.current.volume * 100}%`,
|
|
immediate: reduceMotion,
|
|
});
|
|
}
|
|
},
|
|
[
|
|
spring,
|
|
setVolume,
|
|
setMuted,
|
|
src,
|
|
poster,
|
|
backgroundColor,
|
|
accentColor,
|
|
foregroundColor,
|
|
deployPictureInPicture,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
audioRef.current.volume = volume;
|
|
audioRef.current.muted = muted;
|
|
}, [volume, muted]);
|
|
|
|
useEffect(() => {
|
|
if (typeof visible !== 'undefined') {
|
|
setRevealed(visible);
|
|
} else {
|
|
setRevealed(
|
|
displayMedia === 'show_all' ||
|
|
(displayMedia !== 'hide_all' && !sensitive),
|
|
);
|
|
}
|
|
}, [visible, sensitive]);
|
|
|
|
useEffect(() => {
|
|
if (!revealed && audioRef.current) {
|
|
audioRef.current.pause();
|
|
suspendAudio();
|
|
}
|
|
}, [suspendAudio, revealed]);
|
|
|
|
useEffect(() => {
|
|
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
|
|
|
const updateProgress = () => {
|
|
nextFrame = requestAnimationFrame(() => {
|
|
if (audioRef.current && audioRef.current.duration > 0) {
|
|
void spring.start({
|
|
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
|
immediate: reduceMotion,
|
|
config: config.stiff,
|
|
});
|
|
}
|
|
|
|
updateProgress();
|
|
});
|
|
};
|
|
|
|
updateProgress();
|
|
|
|
return () => {
|
|
cancelAnimationFrame(nextFrame);
|
|
};
|
|
}, [spring]);
|
|
|
|
const togglePlay = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (audioRef.current.paused) {
|
|
resumeAudio();
|
|
void audioRef.current.play();
|
|
} else {
|
|
audioRef.current.pause();
|
|
suspendAudio();
|
|
}
|
|
}, [resumeAudio, suspendAudio]);
|
|
|
|
const handlePlay = useCallback(() => {
|
|
setPaused(false);
|
|
}, []);
|
|
|
|
const handlePause = useCallback(() => {
|
|
setPaused(true);
|
|
}, []);
|
|
|
|
const handleProgress = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const lastTimeRange = audioRef.current.buffered.length - 1;
|
|
|
|
if (lastTimeRange > -1) {
|
|
void spring.start({
|
|
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
|
immediate: reduceMotion,
|
|
});
|
|
}
|
|
}, [spring]);
|
|
|
|
const handleVolumeChange = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
setVolume(audioRef.current.volume);
|
|
setMuted(audioRef.current.muted);
|
|
|
|
void spring.start({
|
|
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
|
immediate: reduceMotion,
|
|
});
|
|
|
|
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
|
}, [spring, setVolume, setMuted]);
|
|
|
|
const handleTimeUpdate = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
setCurrentTime(audioRef.current.currentTime);
|
|
}, [setCurrentTime]);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const effectivelyMuted =
|
|
audioRef.current.muted || audioRef.current.volume === 0;
|
|
|
|
if (effectivelyMuted) {
|
|
audioRef.current.muted = false;
|
|
|
|
if (audioRef.current.volume === 0) {
|
|
audioRef.current.volume = 0.05;
|
|
}
|
|
} else {
|
|
audioRef.current.muted = true;
|
|
}
|
|
}, []);
|
|
|
|
const toggleReveal = useCallback(() => {
|
|
if (onToggleVisibility) {
|
|
onToggleVisibility();
|
|
} else {
|
|
setRevealed((value) => !value);
|
|
}
|
|
}, [onToggleVisibility, setRevealed]);
|
|
|
|
const handleVolumeMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const handleVolumeMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
|
|
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
|
};
|
|
|
|
const handleVolumeMouseMove = (e: MouseEvent) => {
|
|
if (!volumeRef.current || !audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const { x } = getPointerPosition(volumeRef.current, e);
|
|
|
|
if (!isNaN(x)) {
|
|
audioRef.current.volume = x;
|
|
audioRef.current.muted = x > 0 ? false : true;
|
|
void spring.start({ volume: `${x * 100}%`, immediate: true });
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleVolumeMouseMove, true);
|
|
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
|
|
|
handleVolumeMouseMove(e.nativeEvent);
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
[spring],
|
|
);
|
|
|
|
const handleSeekMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const handleSeekMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleSeekMouseMove, true);
|
|
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
|
|
|
setDragging(false);
|
|
resumeAudio();
|
|
void audioRef.current?.play();
|
|
};
|
|
|
|
const handleSeekMouseMove = (e: MouseEvent) => {
|
|
if (!seekRef.current || !audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const { x } = getPointerPosition(seekRef.current, e);
|
|
const newTime = audioRef.current.duration * x;
|
|
|
|
if (!isNaN(newTime)) {
|
|
audioRef.current.currentTime = newTime;
|
|
void spring.start({ progress: `${x * 100}%`, immediate: true });
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleSeekMouseMove, true);
|
|
document.addEventListener('mouseup', handleSeekMouseUp, true);
|
|
|
|
setDragging(true);
|
|
audioRef.current?.pause();
|
|
handleSeekMouseMove(e.nativeEvent);
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
[setDragging, spring, resumeAudio],
|
|
);
|
|
|
|
const handleMouseEnter = useCallback(() => {
|
|
setHovered(true);
|
|
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setHovered(false);
|
|
}, HOVER_FADE_DELAY);
|
|
}, [setHovered]);
|
|
|
|
const handleMouseMove = useCallback(() => {
|
|
setHovered(true);
|
|
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setHovered(false);
|
|
}, HOVER_FADE_DELAY);
|
|
}, [setHovered]);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
setHovered(false);
|
|
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
}, [setHovered]);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
setHovered(true);
|
|
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setHovered(false);
|
|
}, HOVER_FADE_DELAY);
|
|
}, [setHovered]);
|
|
|
|
const handleLoadedData = useCallback(() => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
setDuration(audioRef.current.duration);
|
|
|
|
if (typeof startTime !== 'undefined') {
|
|
audioRef.current.currentTime = startTime;
|
|
}
|
|
|
|
if (typeof startVolume !== 'undefined') {
|
|
audioRef.current.volume = startVolume;
|
|
}
|
|
|
|
if (typeof startMuted !== 'undefined') {
|
|
audioRef.current.muted = startMuted;
|
|
}
|
|
}, [setDuration, startTime, startVolume, startMuted]);
|
|
|
|
const handleCanPlayThrough = useCallback(() => {
|
|
if (startPlaying) {
|
|
resumeAudio();
|
|
void audioRef.current?.play();
|
|
}
|
|
}, [startPlaying, resumeAudio]);
|
|
|
|
const seekBy = (time: number) => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const newTime = audioRef.current.currentTime + time;
|
|
|
|
if (!isNaN(newTime)) {
|
|
audioRef.current.currentTime = newTime;
|
|
}
|
|
};
|
|
|
|
const handleAudioKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
// On the audio element or the seek bar, we can safely use the space bar
|
|
// for playback control because there are no buttons to press
|
|
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
togglePlay();
|
|
}
|
|
},
|
|
[togglePlay],
|
|
);
|
|
|
|
const handleSkipBackward = useCallback(() => {
|
|
seekBy(-5);
|
|
}, []);
|
|
|
|
const handleSkipForward = useCallback(() => {
|
|
seekBy(5);
|
|
}, []);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
const updateVolumeBy = (step: number) => {
|
|
if (!audioRef.current) {
|
|
return;
|
|
}
|
|
|
|
const newVolume = audioRef.current.volume + step;
|
|
|
|
if (!isNaN(newVolume)) {
|
|
audioRef.current.volume = newVolume;
|
|
audioRef.current.muted = newVolume > 0 ? false : true;
|
|
}
|
|
};
|
|
|
|
switch (e.key) {
|
|
case 'k':
|
|
case ' ':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
togglePlay();
|
|
break;
|
|
case 'm':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleMute();
|
|
break;
|
|
case 'j':
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
seekBy(-5);
|
|
break;
|
|
case 'l':
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
seekBy(5);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
updateVolumeBy(0.15);
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
updateVolumeBy(-0.15);
|
|
break;
|
|
}
|
|
},
|
|
[togglePlay, toggleMute],
|
|
);
|
|
|
|
const springForBand0 = useSpring({
|
|
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
|
config: config.wobbly,
|
|
});
|
|
const springForBand1 = useSpring({
|
|
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
|
config: config.wobbly,
|
|
});
|
|
const springForBand2 = useSpring({
|
|
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
|
config: config.wobbly,
|
|
});
|
|
|
|
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
|
const effectivelyMuted = muted || volume === 0;
|
|
|
|
return (
|
|
<div
|
|
className={classNames('audio-player', { inactive: !revealed })}
|
|
ref={playerRef}
|
|
style={
|
|
{
|
|
'--player-background-color': backgroundColor,
|
|
'--player-foreground-color': foregroundColor,
|
|
'--player-accent-color': accentColor,
|
|
} as React.CSSProperties
|
|
}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onTouchEnd={handleTouchEnd}
|
|
role='button'
|
|
tabIndex={0}
|
|
onKeyDownCapture={handleKeyDown}
|
|
aria-label={alt}
|
|
lang={lang}
|
|
>
|
|
{blurhash && (
|
|
<Blurhash
|
|
hash={blurhash}
|
|
className={classNames('media-gallery__preview', {
|
|
'media-gallery__preview--hidden': revealed,
|
|
})}
|
|
dummy={!useBlurhash}
|
|
/>
|
|
)}
|
|
|
|
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
|
|
src={src}
|
|
ref={handleAudioRef}
|
|
preload={startPlaying ? 'auto' : 'none'}
|
|
onPlay={handlePlay}
|
|
onPause={handlePause}
|
|
onProgress={handleProgress}
|
|
onLoadedData={handleLoadedData}
|
|
onCanPlayThrough={handleCanPlayThrough}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onVolumeChange={handleVolumeChange}
|
|
crossOrigin='anonymous'
|
|
/>
|
|
|
|
<div
|
|
className='video-player__seek'
|
|
aria-valuemin={0}
|
|
aria-valuenow={progress}
|
|
aria-valuemax={100}
|
|
onMouseDown={handleSeekMouseDown}
|
|
onKeyDownCapture={handleAudioKeyDown}
|
|
ref={seekRef}
|
|
role='slider'
|
|
tabIndex={0}
|
|
>
|
|
<animated.div
|
|
className='video-player__seek__buffer'
|
|
style={{ width: style.buffer }}
|
|
/>
|
|
<animated.div
|
|
className='video-player__seek__progress'
|
|
style={{ width: style.progress }}
|
|
/>
|
|
|
|
<animated.span
|
|
className={classNames('video-player__seek__handle', {
|
|
active: dragging,
|
|
})}
|
|
style={{ left: style.progress }}
|
|
/>
|
|
</div>
|
|
|
|
<div className='audio-player__controls'>
|
|
<div className='audio-player__controls__play'>
|
|
<button
|
|
type='button'
|
|
title={intl.formatMessage(messages.skipBackward)}
|
|
aria-label={intl.formatMessage(messages.skipBackward)}
|
|
className='player-button'
|
|
onClick={handleSkipBackward}
|
|
>
|
|
<Icon id='' icon={Replay5Icon} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className='audio-player__controls__play'>
|
|
<svg
|
|
className='audio-player__visualizer'
|
|
viewBox='0 0 124 124'
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
>
|
|
<animated.circle
|
|
opacity={0.5}
|
|
cx={57}
|
|
cy={62.5}
|
|
r={springForBand0.r}
|
|
fill='var(--player-accent-color)'
|
|
/>
|
|
<animated.circle
|
|
opacity={0.5}
|
|
cx={65}
|
|
cy={57.5}
|
|
r={springForBand1.r}
|
|
fill='var(--player-accent-color)'
|
|
/>
|
|
<animated.circle
|
|
opacity={0.5}
|
|
cx={63}
|
|
cy={66.5}
|
|
r={springForBand2.r}
|
|
fill='var(--player-accent-color)'
|
|
/>
|
|
|
|
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
|
<rect
|
|
x={14}
|
|
y={14}
|
|
width={96}
|
|
height={96}
|
|
fill={`url(#${accessibilityId}-pattern)`}
|
|
/>
|
|
<rect
|
|
x={14}
|
|
y={14}
|
|
width={96}
|
|
height={96}
|
|
fill='var(--player-background-color'
|
|
opacity={0.45}
|
|
/>
|
|
</g>
|
|
|
|
<defs>
|
|
<pattern
|
|
id={`${accessibilityId}-pattern`}
|
|
patternContentUnits='objectBoundingBox'
|
|
width='1'
|
|
height='1'
|
|
>
|
|
<use href={`#${accessibilityId}-image`} />
|
|
</pattern>
|
|
|
|
<clipPath id={`${accessibilityId}-clip`}>
|
|
<rect
|
|
x={14}
|
|
y={14}
|
|
width={96}
|
|
height={96}
|
|
rx={48}
|
|
fill='white'
|
|
/>
|
|
</clipPath>
|
|
|
|
<image
|
|
id={`${accessibilityId}-image`}
|
|
href={poster}
|
|
width={1}
|
|
height={1}
|
|
preserveAspectRatio='none'
|
|
/>
|
|
</defs>
|
|
</svg>
|
|
|
|
<button
|
|
type='button'
|
|
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
|
aria-label={intl.formatMessage(
|
|
paused ? messages.play : messages.pause,
|
|
)}
|
|
className='player-button'
|
|
onClick={togglePlay}
|
|
>
|
|
<Icon
|
|
id={paused ? 'play' : 'pause'}
|
|
icon={paused ? PlayArrowIcon : PauseIcon}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className='audio-player__controls__play'>
|
|
<button
|
|
type='button'
|
|
title={intl.formatMessage(messages.skipForward)}
|
|
aria-label={intl.formatMessage(messages.skipForward)}
|
|
className='player-button'
|
|
onClick={handleSkipForward}
|
|
>
|
|
<Icon id='' icon={Forward5Icon} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<SpoilerButton
|
|
hidden={revealed || editable}
|
|
sensitive={sensitive ?? false}
|
|
onClick={toggleReveal}
|
|
matchedFilters={matchedFilters}
|
|
/>
|
|
|
|
<div
|
|
className={classNames('video-player__controls', { active: hovered })}
|
|
>
|
|
<div className='video-player__buttons-bar'>
|
|
<div className='video-player__buttons left'>
|
|
<button
|
|
type='button'
|
|
title={intl.formatMessage(
|
|
muted ? messages.unmute : messages.mute,
|
|
)}
|
|
aria-label={intl.formatMessage(
|
|
muted ? messages.unmute : messages.mute,
|
|
)}
|
|
className='player-button'
|
|
onClick={toggleMute}
|
|
>
|
|
<Icon
|
|
id={muted ? 'volume-off' : 'volume-up'}
|
|
icon={muted ? VolumeOffIcon : VolumeUpIcon}
|
|
/>
|
|
</button>
|
|
|
|
<div
|
|
className='video-player__volume active'
|
|
ref={volumeRef}
|
|
onMouseDown={handleVolumeMouseDown}
|
|
role='slider'
|
|
aria-valuemin={0}
|
|
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
|
|
aria-valuemax={100}
|
|
tabIndex={0}
|
|
>
|
|
<animated.div
|
|
className='video-player__volume__current'
|
|
style={{ width: style.volume }}
|
|
/>
|
|
|
|
<animated.span
|
|
className={classNames('video-player__volume__handle')}
|
|
style={{ left: style.volume }}
|
|
/>
|
|
</div>
|
|
|
|
<span className='video-player__time'>
|
|
<span className='video-player__time-current'>
|
|
{formatTime(Math.floor(currentTime))}
|
|
</span>
|
|
<span className='video-player__time-sep'>/</span>
|
|
<span className='video-player__time-total'>
|
|
{formatTime(Math.floor(loadedDuration))}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div className='video-player__buttons right'>
|
|
{!editable && (
|
|
<>
|
|
<button
|
|
type='button'
|
|
className='player-button'
|
|
onClick={toggleReveal}
|
|
>
|
|
<FormattedMessage
|
|
id='media_gallery.hide'
|
|
defaultMessage='Hide'
|
|
/>
|
|
</button>
|
|
|
|
<a
|
|
title={intl.formatMessage(messages.download)}
|
|
aria-label={intl.formatMessage(messages.download)}
|
|
className='video-player__download__icon player-button'
|
|
href={src}
|
|
download
|
|
>
|
|
<Icon id='download' icon={DownloadIcon} />
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// eslint-disable-next-line import/no-default-export
|
|
export default Audio;
|