Soundboard: naming a clip used window.prompt, which Electron does not implement — it returned nothing, the flow aborted in silence, and adding a sound worked in the browser while doing nothing at all in the desktop app. Replaced with a two-step field inside the popover, identical in both. Spotify, three separate defects behind the two symptoms reported: Out of sync — a 20s poll stacked on the activity store's 5s debounce left everyone else on the previous track for up to 25s. The next poll is now scheduled just past the current track's end instead of on a fixed interval, and a track change bypasses the debounce (it happens once every few minutes; the debounce exists for chatty producers). Vanishing — a paused track, and the silent gap Spotify reports between two songs, both cleared the activity outright. Pausing is now carried as state rather than absence, and an empty answer is tolerated for 25s before the block comes down. Progress bar — timestamps are computed with the server's clock and were drawn against the viewer's, so any drift displaced the bar; and it kept advancing after a pause until the next poll. The ready payload now carries server time so each client can correct its own offset, and the bar freezes when paused. Tray, native notifications and system audio in screen share were all found already implemented and wired end to end; recorded in the roadmap rather than built again.
214 lines
7.8 KiB
TypeScript
214 lines
7.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { api, type SoundboardSound } from '../../api/client';
|
||
import { wsSend } from '../../hooks/useWebSocket';
|
||
import { useTransferStore } from '../../stores/transferStore';
|
||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||
import { useT } from '../../i18n';
|
||
|
||
interface SoundboardPopoverProps {
|
||
spaceId: string;
|
||
canManage: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
/** Clips are short gags; anything larger is a music file in disguise. */
|
||
const MAX_SOUND_BYTES = 2 * 1024 * 1024;
|
||
|
||
export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPopoverProps) {
|
||
const t = useT();
|
||
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
// Two-step add: pick the file, then name it in a field right here. The first
|
||
// version asked with window.prompt, which Electron does not implement — it
|
||
// returned nothing and the flow aborted in silence, so adding a sound worked
|
||
// in the browser and did nothing at all in the desktop app.
|
||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||
const [pendingName, setPendingName] = useState('');
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
api.soundboard.list(spaceId)
|
||
.then(({ sounds: list }) => { if (!cancelled) setSounds(list); })
|
||
.catch(() => { /* an empty board is the honest fallback */ });
|
||
return () => { cancelled = true; };
|
||
}, [spaceId]);
|
||
|
||
useEffect(() => {
|
||
const handlePointer = (e: MouseEvent | TouchEvent) => {
|
||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||
};
|
||
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('mousedown', handlePointer);
|
||
document.addEventListener('touchstart', handlePointer);
|
||
document.addEventListener('keydown', handleKey);
|
||
return () => {
|
||
document.removeEventListener('mousedown', handlePointer);
|
||
document.removeEventListener('touchstart', handlePointer);
|
||
document.removeEventListener('keydown', handleKey);
|
||
};
|
||
}, [onClose]);
|
||
|
||
// Fire and forget: the server echoes the clip back to everyone in the call,
|
||
// this client included, so the presser hears exactly what the others hear —
|
||
// including the server's refusal when the cooldown is still running.
|
||
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
|
||
|
||
const pickFile = (file: File) => {
|
||
setError('');
|
||
if (file.size > MAX_SOUND_BYTES) {
|
||
setError(t('soundboard.tooLarge'));
|
||
return;
|
||
}
|
||
setPendingFile(file);
|
||
setPendingName(file.name.replace(/\.[^.]+$/, '').slice(0, 32));
|
||
};
|
||
|
||
const cancelPending = () => {
|
||
setPendingFile(null);
|
||
setPendingName('');
|
||
if (fileRef.current) fileRef.current.value = '';
|
||
};
|
||
|
||
const confirmPending = async () => {
|
||
const file = pendingFile;
|
||
const name = pendingName.trim();
|
||
if (!file || !name) return;
|
||
|
||
setUploading(true);
|
||
setError('');
|
||
try {
|
||
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||
const { filename } = await waitForTransferAttachment(tid);
|
||
const created = await api.soundboard.add(spaceId, name, filename);
|
||
setSounds((prev) => [...prev, created]);
|
||
cancelPending();
|
||
} catch {
|
||
// Distinct from the size check above: reporting every failure as "too
|
||
// large" sends people to shrink a file that was never the problem.
|
||
setError(t('soundboard.uploadFailed'));
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleRemove = async (soundId: string) => {
|
||
const previous = sounds;
|
||
setSounds((prev) => prev.filter((s) => s.id !== soundId));
|
||
try {
|
||
await api.soundboard.remove(soundId);
|
||
} catch {
|
||
setSounds(previous);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={panelRef}
|
||
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden p-3 shadow-xl"
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-[12px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||
{t('soundboard.title')}
|
||
</span>
|
||
{canManage && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={uploading}
|
||
className="text-[11px] text-accent-primary hover:underline disabled:opacity-50"
|
||
>
|
||
{uploading ? t('soundboard.adding') : t('soundboard.add')}
|
||
</button>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="audio/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) pickFile(file);
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
|
||
|
||
{pendingFile && (
|
||
<div className="mb-2 p-2 rounded-lg bg-surface-elevated/60">
|
||
<label className="block text-[11px] text-txt-tertiary mb-1">
|
||
{t('soundboard.namePrompt')}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={pendingName}
|
||
maxLength={32}
|
||
autoFocus
|
||
onChange={(e) => setPendingName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
// Scoped here so Enter does not reach the composer behind the popover.
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter' && pendingName.trim()) void confirmPending();
|
||
if (e.key === 'Escape') cancelPending();
|
||
}}
|
||
className="input-search w-full mb-2"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void confirmPending()}
|
||
disabled={uploading || !pendingName.trim()}
|
||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||
>
|
||
{uploading ? t('soundboard.adding') : t('soundboard.confirm')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={cancelPending}
|
||
disabled={uploading}
|
||
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||
>
|
||
{t('soundboard.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{sounds.length === 0 ? (
|
||
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-1.5 max-h-[220px] overflow-y-auto scrollbar-thin">
|
||
{sounds.map((sound) => (
|
||
<div key={sound.id} className="relative group">
|
||
<button
|
||
type="button"
|
||
onClick={() => play(sound.id)}
|
||
className="w-full px-2 py-2.5 rounded-lg bg-surface-elevated text-txt-secondary hover:text-txt-primary hover:brightness-125 transition-all text-[11px] font-medium truncate"
|
||
title={sound.name}
|
||
>
|
||
{sound.name}
|
||
</button>
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleRemove(sound.id)}
|
||
title={t('soundboard.remove')}
|
||
aria-label={t('soundboard.remove')}
|
||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|