import { MatrixClient } from 'matrix-js-sdk'; import { downloadMedia, mxcUrlToHttp } from './matrix'; // [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now // lives in the soundboard pack plugin (plugins/soundboard); this module only // handles resolving an mxc clip for playback + local preview. export const SOUNDBOARD_NAME_MAX = 24; /** Keep clips short: they publish to every peer and hold a track open. */ export const SOUNDBOARD_MAX_CLIP_BYTES = 1024 * 1024; // 1 MB export const SOUNDBOARD_MAX_CLIPS = 40; export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac'; // Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download // it. [Gitea #57] Clips are decrypted media held live via `blob:` URLs, so the // cache is capped LRU-style (oldest entry revoked on evict) and fully revoked // on logout — see clearSoundboardClipCache(). const objectUrlCache = new Map(); /** Cap is global (across every pack), not per-pack like SOUNDBOARD_MAX_CLIPS. */ const OBJECT_URL_CACHE_MAX = 64; /** * Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch * without credentials. Authenticated media (MSC3916) can't be fetched from the * widget's realm, so the host downloads it (auth handled by the service worker) * and hands the widget a same-session blob URL instead. */ export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise => { const cached = objectUrlCache.get(mxcUrl); if (cached) { // Refresh recency: re-insert so this entry is last to be evicted. objectUrlCache.delete(mxcUrl); objectUrlCache.set(mxcUrl, cached); return cached; } const httpUrl = mxcUrlToHttp(mx, mxcUrl, true); if (!httpUrl) throw new Error('invalid mxc url'); const blob = await downloadMedia(httpUrl); const objectUrl = URL.createObjectURL(blob); if (objectUrlCache.size >= OBJECT_URL_CACHE_MAX) { // Map preserves insertion order, so the first key is the least recently used. const oldestKey = objectUrlCache.keys().next().value; if (oldestKey !== undefined) { const oldestUrl = objectUrlCache.get(oldestKey); if (oldestUrl) URL.revokeObjectURL(oldestUrl); objectUrlCache.delete(oldestKey); } } objectUrlCache.set(mxcUrl, objectUrl); return objectUrl; }; /** * [Gitea #57] Revoke every cached soundboard clip blob URL and empty the * cache. Decrypted clip bytes must not stay reachable past logout — call this * from every logout/clear-cache path alongside clearPlaintextCaches(). */ export const clearSoundboardClipCache = (): void => { objectUrlCache.forEach((objectUrl) => URL.revokeObjectURL(objectUrl)); objectUrlCache.clear(); }; /** * Play a resolved clip locally so the person who pressed it gets immediate * feedback — LiveKit doesn't loop a participant's own published track back to * them, so without this the presser would hear nothing. `volume` is 0–1. * Returns the audio element so callers can track when it ends (or undefined if * playback couldn't start). */ export const playClipLocally = ( objectUrl: string, volume: number, ): HTMLAudioElement | undefined => { try { const audio = new Audio(objectUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.play().catch(() => undefined); return audio; } catch { return undefined; } }; /** Read an audio file's duration in milliseconds from its metadata (no playback). */ export const getAudioDurationMs = (file: Blob): Promise => 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; });