import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; import {Registry} from "tc-shared/events"; import {format_time} from "tc-shared/ui/frames/chat"; import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache"; import {FileInfo, FileManager} from "tc-shared/file/FileManager"; import { FileDownloadTransfer, FileTransferState, ResponseTransferTarget, TransferProvider, TransferTargetType } from "tc-shared/file/Transfer"; import {server_connections} from "tc-shared/ui/frames/connection_handlers"; const icon_cache: ImageCache = new ImageCache("icons"); export interface IconManagerEvents { notify_icon_state_changed: { icon_id: number, server_unique_id: string, icon: LocalIcon }, } //TODO: Invalidate icon after certain time if loading has failed and try to redownload (only if an icon loader has been set!) export type IconLoader = (icon?: LocalIcon) => Promise; export class LocalIcon { readonly icon_id: number; readonly server_unique_id: string; readonly status_change_callbacks: ((icon?: LocalIcon) => void)[] = []; status: "loading" | "loaded" | "empty" | "error" | "destroyed"; loaded_url?: string; error_message?: string; private callback_icon_loader: IconLoader; constructor(id: number, server: string, loader_or_response: Response | IconLoader | undefined) { this.icon_id = id; this.server_unique_id = server; if(id >= 0 && id <= 1000) { /* Internal TeaSpeak icons. These must be handled differently! */ this.status = "loaded"; } else { this.status = "loading"; if(loader_or_response instanceof Response) { this.set_image(loader_or_response).catch(error => { log.error(LogCategory.GENERAL, tr("Icon set image method threw an unexpected error: %o"), error); this.status = "error"; this.error_message = "unexpected parse error"; this.triggerStatusChange(); }); } else { this.callback_icon_loader = loader_or_response; this.load().catch(error => { log.error(LogCategory.GENERAL, tr("Icon load method threw an unexpected error: %o"), error); this.status = "error"; this.error_message = "unexpected load error"; this.triggerStatusChange(); }).then(() => { this.callback_icon_loader = undefined; /* release resources captured by possible closures */ }); } } } private triggerStatusChange() { for(const lister of this.status_change_callbacks.slice(0)) lister(this); } /* called within the CachedIconManager */ protected destroy() { if(typeof this.loaded_url === "string" && URL.revokeObjectURL) URL.revokeObjectURL(this.loaded_url); this.status = "destroyed"; this.loaded_url = undefined; this.error_message = undefined; this.triggerStatusChange(); this.status_change_callbacks.splice(0, this.status_change_callbacks.length); } private async load() { if(!icon_cache.setupped()) await icon_cache.setup(); let response = await icon_cache.resolve_cached("icon_" + this.server_unique_id + "_" + this.icon_id); //TODO age! if(!response) { if(typeof this.callback_icon_loader !== "function") { this.status = "empty"; this.triggerStatusChange(); return; } try { response = await this.callback_icon_loader(this); } catch (error) { log.warn(LogCategory.GENERAL, tr("Failed to download icon %d: %o"), this.icon_id, error); await this.set_error(typeof error === "string" ? error : tr("Failed to load icon")); return; } try { await this.set_image(response); } catch (error) { log.error(LogCategory.GENERAL, tr("Failed to update icon image for icon %d: %o"), this.icon_id, error); await this.set_error(typeof error === "string" ? error : tr("Failed to update icon from downloaded file")); return; } return; } this.loaded_url = await response_to_url(response); this.status = "loaded"; this.triggerStatusChange(); } async set_image(response: Response) { if(this.icon_id >= 0 && this.icon_id <= 1000) throw "Could not set image for internal icon"; const type = image_type(response.headers.get('X-media-bytes')); if(type === ImageType.UNKNOWN) throw "unknown image type"; const media = media_image_type(type); await icon_cache.put_cache("icon_" + this.server_unique_id + "_" + this.icon_id, response.clone(), "image/" + media); this.loaded_url = await response_to_url(response); this.status = "loaded"; this.triggerStatusChange(); } set_error(error: string) { if(this.status === "loaded" || this.status === "destroyed") return; if(this.status === "error" && this.error_message === error) return; this.status = "error"; this.error_message = error; this.triggerStatusChange(); } async await_loading() { await new Promise(resolve => { if(this.status !== "loading") { resolve(); return; } const callback = () => { if(this.status === "loading") return; this.status_change_callbacks.remove(callback); resolve(); }; this.status_change_callbacks.push(callback); }) } } async function response_to_url(response: Response) { if(!response.headers.has('X-media-bytes')) throw "missing media bytes"; const type = image_type(response.headers.get('X-media-bytes')); const media = media_image_type(type); const blob = await response.blob(); if(blob.type !== "image/" + media) return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)); else return URL.createObjectURL(blob) } class CachedIconManager { private loaded_icons: {[id: string]:LocalIcon} = {}; async clear_cache() { await icon_cache.reset(); this.clear_memory_cache(); } clear_memory_cache() { for(const icon_id of Object.keys(this.loaded_icons)) this.loaded_icons[icon_id]["destroy"](); this.loaded_icons = {}; } load_icon(id: number, server_unique_id: string, fallback_load?: IconLoader) : LocalIcon { const cache_id = server_unique_id + "_" + (id >>> 0); if(this.loaded_icons[cache_id]) return this.loaded_icons[cache_id]; return (this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, fallback_load)); } async put_icon(id: number, server_unique_id: string, icon: Response) { const cache_id = server_unique_id + "_" + (id >>> 0); if(this.loaded_icons[cache_id]) await this.loaded_icons[cache_id].set_image(icon); else { const licon = this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, icon); await new Promise((resolve, reject) => { const cb = () => { licon.status_change_callbacks.remove(cb); if(licon.status === "loaded") resolve(); else reject(licon.status === "error" ? licon.error_message || tr("Unknown error") : tr("Invalid status")); }; licon.status_change_callbacks.push(cb); }) } } } export const icon_cache_loader = new CachedIconManager(); window.addEventListener("beforeunload", () => { icon_cache_loader.clear_memory_cache(); }); (window as any).flush_icon_cache = async () => { icon_cache_loader.clear_memory_cache(); await icon_cache_loader.clear_cache(); server_connections.all_connections().forEach(e => { e.fileManager.icons.flush_cache(); }); }; type IconManagerLoadingData = { result: "success" | "error" | "unset"; next_retry?: number; error?: string; } export class IconManager { handle: FileManager; readonly events: Registry; private loading_timestamps: {[key: number]: IconManagerLoadingData} = {}; constructor(handle: FileManager) { this.handle = handle; this.events = new Registry(); } destroy() { this.loading_timestamps = {}; } async delete_icon(id: number) : Promise { if(id <= 1000) throw "invalid id!"; await this.handle.deleteFile({ name: '/icon_' + id }); } iconList() : Promise { return this.handle.requestFileList("/icons"); } createIconDownload(id: number) : FileDownloadTransfer { return this.handle.initializeFileDownload({ path: "", name: "/icon_" + id, targetSupplier: async () => await TransferProvider.provider().createResponseTarget() }); } private async server_icon_loader(icon: LocalIcon) : Promise { const loading_data: IconManagerLoadingData = this.loading_timestamps[icon.icon_id] || (this.loading_timestamps[icon.icon_id] = { result: "unset" }); if(loading_data.result === "error") { if(!loading_data.next_retry || loading_data.next_retry > Date.now()) { log.debug(LogCategory.GENERAL, tr("Don't retry icon download from server. We'll try again in %s"), !loading_data.next_retry ? tr("never") : format_time(loading_data.next_retry - Date.now(), tr("1 second"))); throw loading_data.error; } } try { let transfer = this.createIconDownload(icon.icon_id); try { await transfer.awaitFinished(); if(transfer.transferState() === FileTransferState.CANCELED) { throw tr("download canceled"); } else if(transfer.transferState() === FileTransferState.ERRORED) { throw transfer.currentError(); } else if(transfer.transferState() === FileTransferState.FINISHED) { } else { throw tr("Unknown transfer finished state"); } } catch(error) { if(error instanceof CommandResult) { if(error.id === ErrorID.FILE_NOT_FOUND) throw tr("Icon could not be found"); else if(error.id === ErrorID.PERMISSION_ERROR) throw tr("No permissions to download icon"); else throw error.extra_message || error.message; } log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error); if(error === transfer.currentError()) throw transfer.currentErrorMessage(); throw typeof error === "string" ? error : tr("Failed to initialize icon download"); } /* could only be tested here, because before we don't know which target we have */ if(transfer.target.type !== TransferTargetType.RESPONSE) throw "unsupported transfer target"; const response = transfer.target as ResponseTransferTarget; if(!response.hasResponse()) throw tr("Transfer has no response"); loading_data.result = "success"; return response.getResponse(); } catch (error) { loading_data.result = "error"; loading_data.error = error as string; loading_data.next_retry = Date.now() + 300 * 1000; throw error; } } static generate_tag(icon: LocalIcon | undefined, options?: { animate?: boolean }) : JQuery { options = options || {}; let icon_container = $.spawn("div").addClass("icon-container icon_empty"); let icon_load_image = $.spawn("div").addClass("icon_loading"); const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", ""); if (icon.icon_id == 0) { icon_load_image = undefined; } else if (icon.icon_id < 1000) { icon_load_image = undefined; icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.icon_id); } else { const loading_done = sync => {//TODO: Show error? if(icon.status === "empty") { icon_load_image.remove(); icon_load_image = undefined; } else if(icon.status === "error") { //TODO: Error icon? icon_load_image.remove(); icon_load_image = undefined; } else { icon_image.attr("src", icon.loaded_url); icon_container.append(icon_image).removeClass("icon_empty"); if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) { icon_image.css("opacity", 0); icon_load_image.animate({opacity: 0}, 50, function () { icon_load_image.remove(); icon_image.animate({opacity: 1}, 150); }); } else { icon_load_image.remove(); icon_load_image = undefined; } } }; if(icon.status !== "loading") loading_done(true); else { const cb = () => { if(icon.status === "loading") return; icon.status_change_callbacks.remove(cb); loading_done(false); }; icon.status_change_callbacks.push(cb); } } if(icon_load_image) icon_load_image.appendTo(icon_container); return icon_container; } generateTag(id: number, options?: { animate?: boolean }) : JQuery { options = options || {}; return IconManager.generate_tag(this.load_icon(id), options); } load_icon(id: number) : LocalIcon { const server_uid = this.handle.connectionHandler.channelTree.server.properties.virtualserver_unique_identifier; let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this)); if(icon.status !== "loading" && icon.status !== "loaded") { this.server_icon_loader(icon).then(response => { return icon.set_image(response); }).catch(error => { console.warn("Failed to update broken cached icon from server: %o", error); }) } return icon; } flush_cache() { this.loading_timestamps = {}; } }