/** * 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(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) => { 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) => { 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 (
{stem} {ext && {ext}}
{formatTime(currentTime)} / {formatTime(duration)}
{attachment && ( )}
); }