feat(soundboard): clip duration, playing indicator, volume layout, name wrap
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on upload via getAudioDurationMs, and captured on preview for existing clips); the preview button now toggles play/stop with a 'now playing' equalizer indicator; reworked the volume control into a fixed cell with a % readout so the slider's max no longer collides with the delete button. Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being truncated with an ellipsis; cards grow to fit. TODO: logged the basic audio-editor / video->audio-extractor as a large project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,10 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what
|
|||||||
|
|
||||||
## 📋 Open Feature Backlog
|
## 📋 Open Feature Backlog
|
||||||
|
|
||||||
|
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
|
||||||
|
|
||||||
|
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
|
||||||
|
|
||||||
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
|
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
|
||||||
|
|
||||||
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched** — `src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx` → `<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
|
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched** — `src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx` → `<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import {
|
import {
|
||||||
|
getAudioDurationMs,
|
||||||
playClipLocally,
|
playClipLocally,
|
||||||
resolveClipObjectUrl,
|
resolveClipObjectUrl,
|
||||||
SOUNDBOARD_ACCEPT,
|
SOUNDBOARD_ACCEPT,
|
||||||
@@ -29,6 +30,49 @@ import {
|
|||||||
} from '../../utils/soundboardClips';
|
} from '../../utils/soundboardClips';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
// Injected once: the little "now playing" equalizer bars animation.
|
||||||
|
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
|
||||||
|
function ensureEqKeyframes() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = EQ_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingBars() {
|
||||||
|
return (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: toRem(3),
|
||||||
|
height: toRem(14),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
background: color.Primary.Main,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
|
||||||
|
const formatClipSeconds = (seconds: number): string => {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
type ClipDraft = {
|
type ClipDraft = {
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
const [busyPreview, setBusyPreview] = useState<string>();
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const [playingKey, setPlayingKey] = useState<string>();
|
||||||
|
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
|
||||||
|
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureEqKeyframes();
|
||||||
|
return () => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const existing = useMemo(() => pack.getClips(), [pack]);
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(() => {
|
||||||
|
audioElRef.current?.pause();
|
||||||
|
audioElRef.current = null;
|
||||||
|
setPlayingKey(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const preview = useCallback(
|
const preview = useCallback(
|
||||||
async (id: string, mxc: string, volume: number) => {
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
// Clicking the clip that's already playing stops it (toggle).
|
||||||
|
if (audioElRef.current && playingKey === id) {
|
||||||
|
stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPlayback(); // stop any other clip first
|
||||||
setBusyPreview(id);
|
setBusyPreview(id);
|
||||||
try {
|
try {
|
||||||
const url = await resolveClipObjectUrl(mx, mxc);
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
playClipLocally(url, volume / 100);
|
const audio = playClipLocally(url, volume / 100);
|
||||||
|
if (audio) {
|
||||||
|
audioElRef.current = audio;
|
||||||
|
setPlayingKey(id);
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
if (Number.isFinite(audio.duration)) {
|
||||||
|
setDurations((prev) => new Map(prev).set(id, audio.duration));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const clear = () => {
|
||||||
|
if (audioElRef.current === audio) audioElRef.current = null;
|
||||||
|
setPlayingKey((k) => (k === id ? undefined : k));
|
||||||
|
};
|
||||||
|
audio.addEventListener('ended', clear);
|
||||||
|
audio.addEventListener('pause', clear);
|
||||||
|
audio.addEventListener('error', clear);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore preview errors */
|
/* ignore preview errors */
|
||||||
} finally {
|
} finally {
|
||||||
setBusyPreview(undefined);
|
setBusyPreview(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, playingKey, stopPlayback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const durationMs = await getAudioDurationMs(file);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
const mxc = res.content_uri;
|
const mxc = res.content_uri;
|
||||||
if (!mxc) throw new Error('Upload failed.');
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
body: name,
|
body: name,
|
||||||
emoji: '',
|
emoji: '',
|
||||||
volume: 100,
|
volume: 100,
|
||||||
info: { mimetype: file.type || undefined, size: file.size },
|
info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
setDraft(key, patch, base);
|
setDraft(key, patch, base);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isPlaying = playingKey === key;
|
||||||
|
const clipSeconds =
|
||||||
|
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={key}
|
key={key}
|
||||||
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="Secondary"
|
variant={isPlaying ? 'Primary' : 'Secondary'}
|
||||||
disabled={busyPreview === key}
|
disabled={busyPreview === key}
|
||||||
onClick={() => preview(key, base.url, rowVolume)}
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
aria-label={`Preview ${rowBody}`}
|
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
|
||||||
>
|
>
|
||||||
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
{busyPreview === key ? (
|
||||||
|
<Spinner size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
aria-label="Clip name"
|
aria-label="Clip name"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="End"
|
||||||
|
gap="100"
|
||||||
|
shrink="No"
|
||||||
|
style={{ width: toRem(52) }}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<PlayingBars />
|
||||||
|
) : (
|
||||||
|
clipSeconds !== undefined && (
|
||||||
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatClipSeconds(clipSeconds)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
|
||||||
<Icon size="50" src={Icons.VolumeHigh} />
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
defaultValue={rowVolume}
|
defaultValue={rowVolume}
|
||||||
disabled={!canEdit || markedDeleted}
|
disabled={!canEdit || markedDeleted}
|
||||||
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1, minWidth: 0 }}
|
||||||
aria-label="Clip volume"
|
aria-label="Clip volume"
|
||||||
/>
|
/>
|
||||||
|
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
|
||||||
|
{rowVolume}%
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{canEdit && !isUpload && (
|
{canEdit && !isUpload && (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
|||||||
{existing.map((c) =>
|
{existing.map((c) =>
|
||||||
renderRow(
|
renderRow(
|
||||||
c.shortcode,
|
c.shortcode,
|
||||||
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
info: c.info,
|
||||||
|
},
|
||||||
false,
|
false,
|
||||||
deleted.has(c.shortcode),
|
deleted.has(c.shortcode),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
aria-label={`Play ${clip.name}`}
|
aria-label={`Play ${clip.name}`}
|
||||||
style={{
|
style={{
|
||||||
width: toRem(76),
|
width: toRem(76),
|
||||||
height: toRem(76),
|
minHeight: toRem(76),
|
||||||
|
height: 'auto',
|
||||||
padding: config.space.S100,
|
padding: config.space.S100,
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
clip.emoji || '🔊'
|
clip.emoji || '🔊'
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{clip.name}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -53,3 +53,20 @@ export const playClipLocally = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Read an audio file's duration in milliseconds from its metadata (no playback). */
|
||||||
|
export const getAudioDurationMs = (file: Blob): Promise<number | undefined> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = 'metadata';
|
||||||
|
const done = (ms: number | undefined) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(ms);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', () =>
|
||||||
|
done(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined),
|
||||||
|
);
|
||||||
|
audio.addEventListener('error', () => done(undefined));
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user