import { encodeBlurHash, validBlurHash } from '../utils/blurHash'; import { fitMediaSize } from '../utils/common'; const STORAGE_KEY = 'paarrot.media.meta'; const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions'; const MAX_ENTRIES = 500; /** Same max box as MImage / MVideo attachment rendering. */ export const ATTACHMENT_MAX_SIZE = 400; export type MediaMeta = { w: number; h: number; blurHash?: string; }; export type MediaDimensions = Pick; type CacheMap = Record; let memoryCache: CacheMap | null = null; const loadCache = (): CacheMap => { if (memoryCache) return memoryCache; try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { memoryCache = JSON.parse(raw) as CacheMap; return memoryCache; } // Migrate older dimensions-only cache once. const legacy = localStorage.getItem(LEGACY_DIMENSIONS_KEY); if (legacy) { memoryCache = JSON.parse(legacy) as CacheMap; localStorage.setItem(STORAGE_KEY, legacy); localStorage.removeItem(LEGACY_DIMENSIONS_KEY); return memoryCache; } } catch { // ignore } memoryCache = {}; return memoryCache; }; const persistCache = (cache: CacheMap) => { memoryCache = cache; try { const keys = Object.keys(cache); if (keys.length > MAX_ENTRIES) { const trimmed: CacheMap = {}; keys.slice(keys.length - MAX_ENTRIES).forEach((key) => { trimmed[key] = cache[key]; }); memoryCache = trimmed; localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); return; } localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); } catch { // Quota / private mode — keep in-memory only. } }; const touchEntry = (mxcUrl: string, patch: Partial): void => { if (!mxcUrl) return; const cache = loadCache(); const prev = cache[mxcUrl]; const next: MediaMeta = { w: patch.w ?? prev?.w ?? 0, h: patch.h ?? prev?.h ?? 0, blurHash: patch.blurHash ?? prev?.blurHash, }; if (next.w <= 0 || next.h <= 0) return; if ( prev && prev.w === next.w && prev.h === next.h && prev.blurHash === next.blurHash ) { return; } if (prev) delete cache[mxcUrl]; cache[mxcUrl] = next; persistCache(cache); }; export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined => { if (!mxcUrl) return undefined; const entry = loadCache()[mxcUrl]; if (!entry || entry.w <= 0 || entry.h <= 0) return undefined; return { w: entry.w, h: entry.h }; }; /** Fit attachment box size using event info, then local dimension cache. */ export const resolveAttachmentBoxSize = ( mxcUrl: string | undefined, w?: number, h?: number, maxSize: number = ATTACHMENT_MAX_SIZE ): { width: number; height: number } => { const cached = mxcUrl && (!w || !h) ? getMediaDimensions(mxcUrl) : undefined; const naturalW = w && w > 0 ? w : cached?.w; const naturalH = h && h > 0 ? h : cached?.h; return fitMediaSize(naturalW ?? 0, naturalH ?? 0, maxSize, maxSize); }; export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => { if (!mxcUrl || w <= 0 || h <= 0) return; touchEntry(mxcUrl, { w, h }); }; export const getMediaBlurHash = (mxcUrl: string): string | undefined => { if (!mxcUrl) return undefined; return validBlurHash(loadCache()[mxcUrl]?.blurHash); }; export const setMediaBlurHash = (mxcUrl: string, blurHash: string, w?: number, h?: number): void => { const hash = validBlurHash(blurHash); if (!mxcUrl || !hash) return; const cache = loadCache(); const prev = cache[mxcUrl]; const nextW = w && w > 0 ? w : prev?.w; const nextH = h && h > 0 ? h : prev?.h; if (!nextW || !nextH) { // Dimensions required for the shared meta entry; skip until we know size. return; } touchEntry(mxcUrl, { w: nextW, h: nextH, blurHash: hash }); }; /** Encode + cache a blurhash from a loaded image/video (idle, non-blocking). */ export const rememberMediaBlurHash = ( mxcUrl: string, media: HTMLImageElement | HTMLVideoElement ): void => { if (!mxcUrl || typeof window === 'undefined') return; const run = () => { try { const w = media instanceof HTMLVideoElement ? media.videoWidth : media.naturalWidth || media.width; const h = media instanceof HTMLVideoElement ? media.videoHeight : media.naturalHeight || media.height; if (w <= 0 || h <= 0) return; setMediaDimensions(mxcUrl, w, h); if (getMediaBlurHash(mxcUrl)) return; const encW = 32; const encH = Math.max(1, Math.round(encW * (h / w))); const hash = encodeBlurHash(media, encW, encH); if (hash) setMediaBlurHash(mxcUrl, hash, w, h); } catch { // Encoding can fail for tainted canvases; ignore. } }; if (typeof window.requestIdleCallback === 'function') { window.requestIdleCallback(() => run(), { timeout: 1500 }); } else { window.setTimeout(run, 0); } };