Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@fontsource-variable/nunito": "5.2.7",
"@fontsource/space-mono": "5.2.9",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-virtual": "^3.13.19",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 63 additions & 1 deletion src/app/components/upload-card/UploadCard.css.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { RadiiVariant, color, config } from 'folds';
import { DefaultReset, RadiiVariant, color, config, toRem } from 'folds';

export const UploadCard = recipe({
base: {
Expand Down Expand Up @@ -34,3 +34,65 @@ export const UploadCardError = style({
padding: `0 ${config.space.S100}`,
color: color.Critical.Main,
});

export const AudioPreviewContainer = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
padding: config.space.S300,
width: '100%',
maxWidth: toRem(400),
boxSizing: 'border-box',
},
]);

export const AudioWaveformContainer = style([
DefaultReset,
{
minHeight: 44,
cursor: 'pointer',
userSelect: 'none',
overflow: 'hidden',
},
]);

export const AudioWaveformBar = style([
DefaultReset,
{
width: 2,
height: 3,
borderRadius: 1,
flexShrink: 0,
transition: 'background-color 40ms, opacity 40ms',
pointerEvents: 'none',
},
]);

export const AudioWaveformBarPlayed = style([
DefaultReset,
{
backgroundColor: color.Secondary.Main,
opacity: 1,
},
]);

export const AudioWaveformBarUnplayed = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.OnContainer,
opacity: 0.5,
},
]);

export const AudioTimeDisplay = style([
DefaultReset,
{
fontVariantNumeric: 'tabular-nums',
color: color.SurfaceVariant.OnContainer,
minWidth: toRem(30),
textAlign: 'right',
flexShrink: 0,
},
]);
177 changes: 176 additions & 1 deletion src/app/components/upload-card/UploadCardRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Chip,
Expand All @@ -14,6 +14,7 @@ import {
toRem,
} from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Play, Pause } from '@phosphor-icons/react';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser';
Expand All @@ -27,6 +28,7 @@ import { roomUploadAtomFamily, TUploadItem, TUploadMetadata } from '$state/room/
import { useObjectURL } from '$hooks/useObjectURL';
import { useMediaConfig } from '$hooks/useMediaConfig';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import * as css from './UploadCard.css';
import { DescriptionEditor } from './UploadDescriptionEditor';

type PreviewImageProps = {
Expand Down Expand Up @@ -71,6 +73,178 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
);
}

const BAR_COUNT = 44;

function formatAudioTime(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}

type PreviewAudioProps = {
fileItem: TUploadItem;
};
function PreviewAudio({ fileItem }: PreviewAudioProps) {
const { originalFile, metadata } = fileItem;
const audioUrl = useObjectURL(originalFile);
const { waveform, audioDuration } = metadata;
const duration = audioDuration ?? 0;

const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const rafRef = useRef<number | null>(null);

const bars = useMemo(() => {
if (!waveform || waveform.length === 0) {
return Array(BAR_COUNT).fill(0.3);
}
const step = waveform.length / BAR_COUNT;
return Array.from({ length: BAR_COUNT }, (_, i) => waveform[Math.floor(i * step)] ?? 0);
}, [waveform]);

const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0;

useEffect(() => {
if (!audioUrl) {
return undefined;
}
const audio = new Audio(audioUrl);
audioRef.current = audio;

audio.onended = () => {
setIsPlaying(false);
setCurrentTime(0);
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};

return () => {
audio.pause();
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
};
}, [audioUrl]);

const startRaf = (audio: HTMLAudioElement) => {
const tick = () => {
setCurrentTime(audio.currentTime);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
};

const stopRaf = () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};

const handlePlayPause = () => {
const audio = audioRef.current;
if (!audio) return;

if (isPlaying) {
audio.pause();
setIsPlaying(false);
stopRaf();
} else {
audio.play().catch(() => {});
setIsPlaying(true);
startRaf(audio);
}
};

const handleScrubClick = (e: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current;
if (!audio || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
audio.currentTime = ratio * duration;
setCurrentTime(audio.currentTime);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
const audio = audioRef.current;
if (!audio || !duration) return;

const SEEK_STEP = 5;
let newTime = currentTime;

if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
newTime = Math.max(0, currentTime - SEEK_STEP);
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
newTime = Math.min(duration, currentTime + SEEK_STEP);
} else if (e.key === 'Home') {
e.preventDefault();
newTime = 0;
} else if (e.key === 'End') {
e.preventDefault();
newTime = duration;
} else {
return;
}

audio.currentTime = newTime;
setCurrentTime(newTime);
};

return (
<Box alignItems="Center" gap="200" className={css.AudioPreviewContainer}>
<IconButton
variant="Secondary"
size="400"
radii="300"
onClick={handlePlayPause}
title={isPlaying ? 'Pause' : 'Play voice message'}
aria-label={isPlaying ? 'Pause' : 'Play voice message'}
aria-pressed={isPlaying}
>
{isPlaying ? <Pause size={20} weight="fill" /> : <Play size={20} weight="fill" />}
</IconButton>

<Box
grow="Yes"
alignItems="Center"
gap="100"
onClick={handleScrubClick}
onKeyDown={handleKeyDown}
className={css.AudioWaveformContainer}
tabIndex={0}
role="slider"
aria-label="Audio position"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Math.floor(currentTime)}
title="Seek"
>
{bars.map((level, i) => {
const barRatio = i / BAR_COUNT;
const played = barRatio <= progress;
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
className={`${css.AudioWaveformBar} ${played ? css.AudioWaveformBarPlayed : css.AudioWaveformBarUnplayed}`}
style={{ height: Math.max(3, Math.round(level * 24)) }}
/>
);
})}
</Box>

<Text size="T200" className={css.AudioTimeDisplay}>
{formatAudioTime(isPlaying ? currentTime : duration)}
</Text>
</Box>
);
}

type MediaPreviewProps = {
fileItem: TUploadItem;
onSpoiler: (marked: boolean) => void;
Expand Down Expand Up @@ -247,6 +421,7 @@ export function UploadCardRenderer({
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.metadata.waveform && <PreviewAudio fileItem={fileItem} />}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
Expand Down
Loading