Add default visualizer for audio upload without poster (#36734)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
|||||||
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||||
import { playerSettings } from 'mastodon/settings';
|
import { playerSettings } from 'mastodon/settings';
|
||||||
|
|
||||||
|
import { AudioVisualizer } from './visualizer';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
@@ -116,7 +118,6 @@ export const Audio: React.FC<{
|
|||||||
const seekRef = useRef<HTMLDivElement>(null);
|
const seekRef = useRef<HTMLDivElement>(null);
|
||||||
const volumeRef = useRef<HTMLDivElement>(null);
|
const volumeRef = useRef<HTMLDivElement>(null);
|
||||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||||
const accessibilityId = useId();
|
|
||||||
|
|
||||||
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
|
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
|
||||||
useAudioContext({ audioElementRef: audioRef });
|
useAudioContext({ audioElementRef: audioRef });
|
||||||
@@ -538,19 +539,6 @@ export const Audio: React.FC<{
|
|||||||
[togglePlay, toggleMute],
|
[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 progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
||||||
const effectivelyMuted = muted || volume === 0;
|
const effectivelyMuted = muted || volume === 0;
|
||||||
|
|
||||||
@@ -641,81 +629,7 @@ export const Audio: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='audio-player__controls__play'>
|
<div className='audio-player__controls__play'>
|
||||||
<svg
|
<AudioVisualizer frequencyBands={frequencyBands} poster={poster} />
|
||||||
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
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
|
|||||||
100
app/javascript/mastodon/features/audio/visualizer.tsx
Normal file
100
app/javascript/mastodon/features/audio/visualizer.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { animated, config, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface AudioVisualizerProps {
|
||||||
|
frequencyBands?: number[];
|
||||||
|
poster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioVisualizer: FC<AudioVisualizerProps> = ({
|
||||||
|
frequencyBands = [],
|
||||||
|
poster,
|
||||||
|
}) => {
|
||||||
|
const accessibilityId = useId();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||||
|
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||||
import { undoUploadCompose } from 'mastodon/actions/compose';
|
import { undoUploadCompose } from 'mastodon/actions/compose';
|
||||||
@@ -17,7 +18,18 @@ import { openModal } from 'mastodon/actions/modal';
|
|||||||
import { Blurhash } from 'mastodon/components/blurhash';
|
import { Blurhash } from 'mastodon/components/blurhash';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import {
|
||||||
|
createAppSelector,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from 'mastodon/store';
|
||||||
|
|
||||||
|
import { AudioVisualizer } from '../../audio/visualizer';
|
||||||
|
|
||||||
|
const selectUserAvatar = createAppSelector(
|
||||||
|
[(state) => state.accounts, (state) => state.meta.get('me') as string],
|
||||||
|
(accounts, myId) => accounts.get(myId)?.avatar_static,
|
||||||
|
);
|
||||||
|
|
||||||
export const Upload: React.FC<{
|
export const Upload: React.FC<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,6 +50,7 @@ export const Upload: React.FC<{
|
|||||||
const sensitive = useAppSelector(
|
const sensitive = useAppSelector(
|
||||||
(state) => state.compose.get('spoiler') as boolean,
|
(state) => state.compose.get('spoiler') as boolean,
|
||||||
);
|
);
|
||||||
|
const userAvatar = useAppSelector(selectUserAvatar);
|
||||||
|
|
||||||
const handleUndoClick = useCallback(() => {
|
const handleUndoClick = useCallback(() => {
|
||||||
dispatch(undoUploadCompose(id));
|
dispatch(undoUploadCompose(id));
|
||||||
@@ -67,6 +80,8 @@ export const Upload: React.FC<{
|
|||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
const preview_url = media.get('preview_url') as string | null;
|
||||||
|
const blurhash = media.get('blurhash') as string | null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -85,17 +100,19 @@ export const Upload: React.FC<{
|
|||||||
<div
|
<div
|
||||||
className='compose-form__upload__thumbnail'
|
className='compose-form__upload__thumbnail'
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: !sensitive
|
backgroundImage:
|
||||||
? `url(${media.get('preview_url') as string})`
|
!sensitive && preview_url ? `url(${preview_url})` : undefined,
|
||||||
: undefined,
|
|
||||||
backgroundPosition: `${x}% ${y}%`,
|
backgroundPosition: `${x}% ${y}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sensitive && (
|
{sensitive && blurhash && (
|
||||||
<Blurhash
|
<Blurhash hash={blurhash} className='compose-form__upload__preview' />
|
||||||
hash={media.get('blurhash') as string}
|
)}
|
||||||
className='compose-form__upload__preview'
|
{!sensitive && !preview_url && (
|
||||||
/>
|
<div className='compose-form__upload__visualizer'>
|
||||||
|
<AudioVisualizer poster={userAvatar} />
|
||||||
|
<Icon id='sound' icon={SoundIcon} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='compose-form__upload__actions'>
|
<div className='compose-form__upload__actions'>
|
||||||
|
|||||||
1
app/javascript/material-icons/400-24px/audio.svg
Normal file
1
app/javascript/material-icons/400-24px/audio.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M280-240v-480h80v480h-80ZM440-80v-800h80v800h-80ZM120-400v-160h80v160h-80Zm480 160v-480h80v480h-80Zm160-160v-160h80v160h-80Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
@@ -775,16 +775,43 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__preview {
|
&__preview,
|
||||||
|
&__visualizer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 6px;
|
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
border-radius: 6px;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__visualizer {
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.audio-player__visualizer {
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0.75;
|
||||||
|
color: var(--player-foreground-color);
|
||||||
|
filter: var(--overlay-icon-shadow);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__thumbnail {
|
&__thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user