Files
cinny/src/app/state/mediaDimensionCache.ts
litruv c286501be8 Improve timeline scroll, media layout, avatars, and emoji coverage.
Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
2026-07-22 20:00:14 +10:00

169 lines
4.9 KiB
TypeScript

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<MediaMeta, 'w' | 'h'>;
type CacheMap = Record<string, MediaMeta>;
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<MediaMeta>): 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);
}
};