2
0

Add default visualizer for audio upload without poster (#36734)

This commit is contained in:
Echo
2025-11-05 16:33:26 +01:00
committed by Claire
parent 3e6a9371b0
commit ef3a95affc
5 changed files with 160 additions and 101 deletions

View File

@@ -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';
@@ -22,6 +22,8 @@ import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
import { displayMedia, useBlurhash } from 'mastodon/initial_state';
import { playerSettings } from 'mastodon/settings';
import { AudioVisualizer } from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
@@ -116,7 +118,6 @@ export const Audio: React.FC<{
const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const accessibilityId = useId();
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
useAudioContext({ audioElementRef: audioRef });
@@ -538,19 +539,6 @@ export const Audio: React.FC<{
[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;
@@ -641,81 +629,7 @@ export const Audio: React.FC<{
</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>
<AudioVisualizer frequencyBands={frequencyBands} poster={poster} />
<button
type='button'

View 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>
);
};

View File

@@ -10,6 +10,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
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 WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose';
@@ -17,7 +18,18 @@ import { openModal } from 'mastodon/actions/modal';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
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<{
id: string;
@@ -38,6 +50,7 @@ export const Upload: React.FC<{
const sensitive = useAppSelector(
(state) => state.compose.get('spoiler') as boolean,
);
const userAvatar = useAppSelector(selectUserAvatar);
const handleUndoClick = useCallback(() => {
dispatch(undoUploadCompose(id));
@@ -67,6 +80,8 @@ export const Upload: React.FC<{
transform: CSS.Transform.toString(transform),
transition,
};
const preview_url = media.get('preview_url') as string | null;
const blurhash = media.get('blurhash') as string | null;
return (
<div
@@ -85,17 +100,19 @@ export const Upload: React.FC<{
<div
className='compose-form__upload__thumbnail'
style={{
backgroundImage: !sensitive
? `url(${media.get('preview_url') as string})`
: undefined,
backgroundImage:
!sensitive && preview_url ? `url(${preview_url})` : undefined,
backgroundPosition: `${x}% ${y}%`,
}}
>
{sensitive && (
<Blurhash
hash={media.get('blurhash') as string}
className='compose-form__upload__preview'
/>
{sensitive && blurhash && (
<Blurhash hash={blurhash} 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'>

View 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

View File

@@ -775,16 +775,43 @@
padding: 8px;
}
&__preview {
&__preview,
&__visualizer {
position: absolute;
width: 100%;
height: 100%;
border-radius: 6px;
z-index: -1;
top: 0;
}
&__preview {
border-radius: 6px;
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 {
width: 100%;
height: 100%;