media cache service
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user