media cache service

This commit is contained in:
2026-04-20 21:57:42 -04:00
parent 28f00e5d87
commit ff6bacea6c
4 changed files with 157 additions and 119 deletions
+1 -1
View File
@@ -161,7 +161,7 @@
>
<div class="info-filename">{image.name || "Untitled Image"}</div>
<div class="info-details">
{image.mimeType}{formatSize(chat.imageSizes.get(image.id.toString()))}{Math.round(zoomLevel * 100)}%
{image.mimeType}{formatSize(chat.getImageSize(image.id))}{Math.round(zoomLevel * 100)}%
</div>
</div>
+17 -118
View File
@@ -13,6 +13,7 @@ import { AccountService } from "./account.svelte";
import { ServerManagementService } from "./server-management.svelte";
import { DirectMessagingService } from "./direct-messaging.svelte";
import { EncryptionService } from "./encryption.svelte";
import { MediaCacheService } from "./media-cache.svelte";
import { sounds } from "./sound.svelte";
export class ChatService {
@@ -26,20 +27,13 @@ export class ChatService {
#server: ServerManagementService;
#dm: DirectMessagingService;
#encryption: EncryptionService;
#media: MediaCacheService;
identity = $state<Identity | null>(null);
#blobUrls = new Map<string, string>();
subscribedChannels = $state(new SvelteSet<string>());
mutedDms = $state(new SvelteSet<string>());
notificationSoundPack = $state("classic");
// These maps are for reactive UI access
#avatarUrls = new SvelteMap<string, string>();
#bannerUrls = new SvelteMap<string, string>();
#serverAvatarUrls = new SvelteMap<string, string>();
#messageImageUrls = new SvelteMap<string, string>();
imageSizes = new SvelteMap<string, number>();
constructor(initialIdentity: Identity | null) {
console.log("ChatService: Initializing with identity:", initialIdentity?.toHexString());
this.identity = initialIdentity;
@@ -53,6 +47,7 @@ export class ChatService {
this.#server = new ServerManagementService();
this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity);
this.#encryption = new EncryptionService(this.#db, () => this.identity);
this.#media = new MediaCacheService(this.#db, () => this.identity);
// Load notification preferences from localStorage
$effect(() => {
@@ -134,104 +129,6 @@ export class ChatService {
}
}
};
// 1. Lazy Image Requesting: Track which images should be visible and request missing BLOBs
$effect(() => {
const currentImages = this.#db.images;
const conn = getConnection();
if (!conn || !this.identity) return;
const currentIds = new Set(currentImages.map(img => img.id.toString()));
// Cleanup old Blob URLs no longer in visible_images
for (const [idStr, url] of this.#blobUrls.entries()) {
if (!currentIds.has(idStr)) {
console.log(`[ChatService] Revoking Blob URL for ${idStr}`);
URL.revokeObjectURL(url);
this.#blobUrls.delete(idStr);
this.#avatarUrls.delete(idStr);
this.#bannerUrls.delete(idStr);
this.#serverAvatarUrls.delete(idStr);
this.#messageImageUrls.delete(idStr);
}
}
// Request blobs for any metadata we have but no data yet
for (const img of currentImages) {
const idStr = img.id.toString();
if (!this.#blobUrls.has(idStr)) {
conn.reducers.requestImageBlob({ imageId: img.id });
}
}
});
// 2. Lazy Image Processing: Process BLOBs as they arrive in visible_image_blobs
$effect(() => {
const blobs = this.#db.imageBlobs;
const currentImages = this.#db.images;
const conn = getConnection();
if (!conn || !this.identity) return;
for (const blob of blobs) {
const idStr = blob.imageId.toString();
if (!this.#blobUrls.has(idStr)) {
const metadata = currentImages.find(img => img.id === blob.imageId);
if (metadata) {
// Use a copy of the data
const dataCopy = blob.data.slice();
this.imageSizes.set(idStr, dataCopy.length);
const browserBlob = new Blob([dataCopy], { type: metadata.mimeType });
const url = URL.createObjectURL(browserBlob);
console.log(`[ChatService] Lazy-loaded Blob URL for ${idStr}: ${url} (${dataCopy.length} bytes)`);
this.#blobUrls.set(idStr, url);
}
}
}
// Update reactive maps for UI
// Avatars/Banners
for (const user of this.users) {
if (user.avatarId) {
const idStr = user.avatarId.toString();
const url = this.#blobUrls.get(idStr);
if (url && this.#avatarUrls.get(idStr) !== url) {
this.#avatarUrls.set(idStr, url);
}
}
if (user.bannerId) {
const idStr = user.bannerId.toString();
const url = this.#blobUrls.get(idStr);
if (url && this.#bannerUrls.get(idStr) !== url) {
this.#bannerUrls.set(idStr, url);
}
}
}
// Server avatars
for (const server of this.servers) {
if (server.avatarId) {
const idStr = server.avatarId.toString();
const url = this.#blobUrls.get(idStr);
if (url && this.#serverAvatarUrls.get(idStr) !== url) {
console.log(`[ChatService] Mapping server avatar URL: ${idStr} -> ${url} for server ${server.name}`);
this.#serverAvatarUrls.set(idStr, url);
} else if (!url) {
// Check if we already requested this
if (!this.#blobUrls.has(idStr)) {
console.log(`[ChatService] Requesting missing server avatar blob for ${server.name}: ${idStr}`);
conn.reducers.requestImageBlob({ imageId: server.avatarId });
}
}
}
}
// Message attachments and others
for (const img of currentImages) {
const idStr = img.id.toString();
const url = this.#blobUrls.get(idStr);
if (url && this.#messageImageUrls.get(idStr) !== url) {
this.#messageImageUrls.set(idStr, url);
}
}
});
}
get activeServerId() {
@@ -646,7 +543,7 @@ export class ChatService {
get activeServerMembers() {
if (!this.activeServerId) return [];
return this.serverMembers.filter((m) => m.serverId === this.activeServerId);
return this.#db.serverMembersByServerId.get(this.activeServerId) || [];
}
get onlineUsers() {
@@ -725,23 +622,23 @@ export class ChatService {
};
getAvatarUrl = (user: { avatarId?: bigint | null } | null | undefined) => {
if (!user || !user.avatarId) return null;
return this.#avatarUrls.get(user.avatarId.toString()) || null;
return this.#media.get(user?.avatarId);
};
getBannerUrl = (user: Types.User | null | undefined) => {
if (!user || !user.bannerId) return null;
return this.#bannerUrls.get(user.bannerId.toString()) || null;
return this.#media.get(user?.bannerId);
};
getServerAvatarUrl = (server: Types.Server | null | undefined) => {
if (!server || !server.avatarId) return null;
return this.#serverAvatarUrls.get(server.avatarId.toString()) || null;
return this.#media.get(server?.avatarId);
};
getImageUrl = (imageId: bigint | null | undefined) => {
if (!imageId) return null;
return this.#messageImageUrls.get(imageId.toString()) || null;
return this.#media.get(imageId);
};
getImageSize = (imageId: bigint | null | undefined) => {
return this.#media.getSize(imageId);
};
uploadImage = async (data: Uint8Array, mimeType: string, name?: string): Promise<bigint> => {
@@ -794,11 +691,13 @@ export class ChatService {
return isSameChannel && isNotMe && currentlyTyping;
})
.map((ta) => {
const member = this.serverMembers.find((m) => m.identity.isEqual(ta.identity));
// Use O(1) lookup from the new keyed map
const userIdHex = ta.identity.toHexString();
const user = this.#db.usersById.get(userIdHex);
return (
member ||
user ||
({
name: `User ${ta.identity.toHexString().substring(0, 8)}`,
name: `User ${userIdHex.substring(0, 8)}`,
} as any)
);
});
+38
View File
@@ -25,6 +25,44 @@ export class DatabaseService {
isMembersReady = $state(false);
isImagesReady = $state(false);
// Keyed Maps for O(1) Lookups
usersById = $derived.by(() => {
const map = new Map<string, Types.User>();
for (const user of this.users) {
map.set(user.identity.toHexString(), user);
}
return map;
});
serversById = $derived.by(() => {
const map = new Map<bigint, Types.Server>();
for (const server of this.servers) {
map.set(server.id, server);
}
return map;
});
channelsById = $derived.by(() => {
const map = new Map<bigint, Types.VisibleChannelRow>();
for (const channel of this.channels) {
map.set(channel.id, channel);
}
return map;
});
serverMembersByServerId = $derived.by(() => {
const map = new Map<bigint, Types.ServerMember[]>();
for (const member of this.serverMembers) {
let list = map.get(member.serverId);
if (!list) {
list = [];
map.set(member.serverId, list);
}
list.push(member);
}
return map;
});
get isReady() {
return this.isUsersReady && this.isServersReady && this.isChannelsReady && this.isMembersReady;
}
+101
View File
@@ -0,0 +1,101 @@
import { SvelteMap } from "svelte/reactivity";
import type { DatabaseService } from "./database.svelte";
import { getConnection } from "../../config";
import type { Identity } from "spacetimedb";
/**
* MediaCacheService manages the lifecycle of Browser Object URLs (Blob URLs).
* It ensures that binary data (BLOBs) received from SpacetimeDB are converted
* to usable URLs and that these URLs are properly revoked when no longer needed
* to prevent memory leaks.
*/
export class MediaCacheService {
#db: DatabaseService;
#identity: () => Identity | null;
// Single source of truth for all media URLs
#urls = new SvelteMap<string, string>();
#sizes = new SvelteMap<string, number>();
constructor(db: DatabaseService, identity: () => Identity | null) {
this.#db = db;
this.#identity = identity;
// 1. Lifecycle Management: Revoke URLs for media that is no longer "visible"
// based on SpacetimeDB view metadata.
$effect(() => {
const currentMetadata = this.#db.images;
const currentIds = new Set(currentMetadata.map(img => img.id.toString()));
for (const [idStr, url] of this.#urls.entries()) {
if (!currentIds.has(idStr)) {
console.log(`[MediaCache] Revoking Blob URL for ${idStr} (Metadata removed)`);
URL.revokeObjectURL(url);
this.#urls.delete(idStr);
this.#sizes.delete(idStr);
}
}
// 2. Proactive Requesting: Request missing binary data for visible metadata
const conn = getConnection();
if (conn && this.#identity()) {
for (const img of currentMetadata) {
const idStr = img.id.toString();
if (!this.#urls.has(idStr)) {
conn.reducers.requestImageBlob({ imageId: img.id });
}
}
}
});
// 3. Blob Processing: Convert binary data to Object URLs as it arrives
$effect(() => {
const blobs = this.#db.imageBlobs;
const imagesMap = this.#db.imagesMap;
for (const blob of blobs) {
const idStr = blob.imageId.toString();
// If we have data but no URL yet, create one
if (!this.#urls.has(idStr)) {
const metadata = imagesMap.get(blob.imageId);
if (metadata) {
this.#sizes.set(idStr, blob.data.length);
const browserBlob = new Blob([blob.data], { type: metadata.mimeType });
const url = URL.createObjectURL(browserBlob);
console.log(`[MediaCache] Created URL for ${idStr}: ${url} (${blob.data.length} bytes)`);
this.#urls.set(idStr, url);
}
}
}
});
}
/**
* Returns a reactive Object URL for a given image ID.
*/
get(imageId: bigint | string | null | undefined): string | null {
if (!imageId) return null;
return this.#urls.get(imageId.toString()) || null;
}
/**
* Returns the size in bytes of the cached blob.
*/
getSize(imageId: bigint | string | null | undefined): number {
if (!imageId) return 0;
return this.#sizes.get(imageId.toString()) || 0;
}
/**
* Clean up everything (e.g. on logout)
*/
clear() {
console.log("[MediaCache] Clearing all media and revoking all URLs.");
for (const url of this.#urls.values()) {
URL.revokeObjectURL(url);
}
this.#urls.clear();
this.#sizes.clear();
}
}