Files
DiscordClone/packages/shared/src/components/channel/AttachmentAudio.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

336 lines
10 KiB
TypeScript

/**
* AttachmentAudio — custom audio player for `audio/*` attachments.
* Ported from the new UI's Fluxer-style layout but simplified for
* our Convex pipeline: takes the already-decrypted blob URL from
* `EncryptedAttachment` instead of resolving an MXC URL itself.
*
* ┌──────────────────────────────────────────────────────────┐
* │ ┌───┐ filename-truncated… .mp3 │
* │ │ ▶ │ ────────────────────────●─── 0:12/3:04│
* │ └───┘ │
* │ ◀) 1x ↓ │
* └──────────────────────────────────────────────────────────┘
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Download,
Pause,
Play,
SpeakerHigh,
SpeakerSlash,
Star,
} from '@phosphor-icons/react';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './AttachmentAudio.module.css';
interface AttachmentAudioProps {
src: string;
filename: string;
attachment?: AttachmentMetadata;
}
const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
function splitFilename(filename: string): { stem: string; ext: string } {
const dot = filename.lastIndexOf('.');
if (dot <= 0 || dot === filename.length - 1) {
return { stem: filename, ext: '' };
}
return { stem: filename.slice(0, dot), ext: filename.slice(dot) };
}
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function AttachmentAudio({ src, filename, attachment }: AttachmentAudioProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [playbackRateIndex, setPlaybackRateIndex] = useState(0);
const { stem, ext } = useMemo(() => splitFilename(filename), [filename]);
// Saved-media wiring — mirrors the ImageLightbox star button so
// audio attachments can be bookmarked into the Media tab.
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const savedList = useQuery(
api.savedMedia.list,
myUserId && attachment
? { userId: myUserId as Id<'userProfiles'> }
: 'skip',
);
const isSaved = !!(
attachment && savedList?.some((m) => m.url === attachment.url)
);
const saveMutation = useMutation(api.savedMedia.save);
const removeMutation = useMutation(api.savedMedia.remove);
const handleToggleSaved = useCallback(async () => {
if (!attachment || !myUserId) return;
try {
if (isSaved) {
await removeMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
});
} else {
await saveMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
kind: attachment.mimeType.split('/')[0],
filename: attachment.filename,
mimeType: attachment.mimeType,
width: attachment.width,
height: attachment.height,
size: attachment.size,
encryptionKey: attachment.key,
encryptionIv: attachment.iv,
});
}
} catch (err) {
console.warn('Failed to toggle saved audio:', err);
}
}, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const handleTime = () => setCurrentTime(el.currentTime);
const handleDuration = () => setDuration(el.duration);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
const handleVolume = () => {
setVolume(el.volume);
setIsMuted(el.muted);
};
el.addEventListener('timeupdate', handleTime);
el.addEventListener('loadedmetadata', handleDuration);
el.addEventListener('durationchange', handleDuration);
el.addEventListener('play', handlePlay);
el.addEventListener('pause', handlePause);
el.addEventListener('ended', handleEnded);
el.addEventListener('volumechange', handleVolume);
return () => {
el.removeEventListener('timeupdate', handleTime);
el.removeEventListener('loadedmetadata', handleDuration);
el.removeEventListener('durationchange', handleDuration);
el.removeEventListener('play', handlePlay);
el.removeEventListener('pause', handlePause);
el.removeEventListener('ended', handleEnded);
el.removeEventListener('volumechange', handleVolume);
};
}, []);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.playbackRate = PLAYBACK_SPEEDS[playbackRateIndex];
}, [playbackRateIndex]);
const handleTogglePlay = useCallback(() => {
const el = audioRef.current;
if (!el || !src) return;
if (el.paused) {
void el.play().catch(() => {});
} else {
el.pause();
}
}, [src]);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const el = audioRef.current;
if (!el) return;
const next = Number(e.target.value);
el.currentTime = next;
setCurrentTime(next);
},
[],
);
const handleToggleMute = useCallback(() => {
const el = audioRef.current;
if (!el) return;
el.muted = !el.muted;
}, []);
const handleVolumeSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const el = audioRef.current;
if (!el) return;
const next = Number(e.target.value);
el.volume = next;
if (next > 0 && el.muted) el.muted = false;
else if (next === 0 && !el.muted) el.muted = true;
},
[],
);
const handleCycleSpeed = useCallback(() => {
setPlaybackRateIndex((i) => (i + 1) % PLAYBACK_SPEEDS.length);
}, []);
const handleDownload = useCallback(() => {
if (!src) return;
const a = document.createElement('a');
a.href = src;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [src, filename]);
const progressRatio =
duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
const progressPercent = `${(progressRatio * 100).toFixed(2)}%`;
const playbackSpeedLabel = `${PLAYBACK_SPEEDS[playbackRateIndex]}x`;
return (
<div className={styles.card}>
<div className={styles.topRow}>
<button
type="button"
className={styles.playButton}
onClick={handleTogglePlay}
disabled={!src}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause size={18} weight="fill" />
) : (
<Play size={18} weight="fill" />
)}
</button>
<div className={styles.filenameRow}>
<span className={styles.filenameStem} title={filename}>
{stem}
</span>
{ext && <span className={styles.filenameExt}>{ext}</span>}
</div>
</div>
<div className={styles.progressRow}>
<div
className={styles.progressTrack}
style={{ ['--progress' as string]: progressPercent }}
>
<div className={styles.progressFill} />
<input
type="range"
className={styles.progressInput}
min={0}
max={duration || 0}
step={0.01}
value={currentTime}
onChange={handleSeek}
disabled={!duration}
aria-label="Seek audio"
/>
</div>
<span className={styles.timeLabel}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className={styles.bottomRow}>
<div className={styles.volumeWrap}>
<div className={styles.volumePopover}>
<div
className={styles.volumeTrack}
style={{
['--volume' as string]: `${(isMuted ? 0 : volume) * 100}%`,
}}
>
<div className={styles.volumeFill} />
<input
type="range"
min={0}
max={1}
step={0.01}
value={isMuted ? 0 : volume}
onChange={handleVolumeSlider}
className={styles.volumeInput}
aria-label="Volume"
/>
</div>
</div>
<button
type="button"
className={styles.iconButton}
onClick={handleToggleMute}
aria-label={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
title={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<SpeakerSlash size={18} weight="fill" />
) : (
<SpeakerHigh size={18} weight="fill" />
)}
</button>
</div>
<div className={styles.bottomRowRight}>
<button
type="button"
className={styles.speedButton}
onClick={handleCycleSpeed}
aria-label={`Playback speed: ${playbackSpeedLabel}`}
title="Playback speed"
>
{playbackSpeedLabel}
</button>
{attachment && (
<button
type="button"
className={styles.iconButton}
onClick={() => void handleToggleSaved()}
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
title={isSaved ? 'Unfavorite' : 'Favorite'}
style={
isSaved
? { color: 'var(--brand-primary, #5865f2)' }
: undefined
}
>
<Star size={18} weight={isSaved ? 'fill' : 'regular'} />
</button>
)}
<button
type="button"
className={styles.iconButton}
onClick={handleDownload}
disabled={!src}
aria-label="Download"
title="Download"
>
<Download size={18} weight="regular" />
</button>
</div>
</div>
<audio
ref={audioRef}
src={src || undefined}
preload="metadata"
className={styles.hiddenAudio}
/>
</div>
);
}