All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
336 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|