Updated the icon API
This commit is contained in:
parent
73f523ee52
commit
8f477437af
6 changed files with 490 additions and 432 deletions
|
@ -4,7 +4,6 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
|||
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
|
||||
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
|
||||
import {IconManager} from "tc-shared/file/Icons";
|
||||
import {AvatarManager} from "tc-shared/file/LocalAvatars";
|
||||
import {
|
||||
CancelReason,
|
||||
|
@ -393,7 +392,6 @@ export class FileManager {
|
|||
private static readonly MAX_CONCURRENT_TRANSFERS = 6;
|
||||
|
||||
readonly connectionHandler: ConnectionHandler;
|
||||
readonly icons: IconManager;
|
||||
readonly avatars: AvatarManager;
|
||||
readonly events : Registry<FileManagerEvents>;
|
||||
readonly finishedTransfers: FinishedFileTransfer[] = [];
|
||||
|
@ -409,7 +407,6 @@ export class FileManager {
|
|||
this.commandHandler = new FileCommandHandler(this);
|
||||
|
||||
this.events = new Registry<FileManagerEvents>();
|
||||
this.icons = new IconManager(this);
|
||||
this.avatars = new AvatarManager(this);
|
||||
|
||||
this.transerUpdateIntervalId = setInterval(() => this.scheduleTransferUpdate(), 1000);
|
||||
|
@ -420,7 +417,6 @@ export class FileManager {
|
|||
this.registeredTransfers_.forEach(e => e.transfer.requestCancel(CancelReason.SERVER_DISCONNECTED));
|
||||
/* all transfers should be unregistered now, or will be soonly */
|
||||
|
||||
this.icons.destroy();
|
||||
this.avatars.destroy();
|
||||
clearInterval(this.transerUpdateIntervalId);
|
||||
}
|
||||
|
@ -503,6 +499,16 @@ export class FileManager {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteIcon(iconId: number) : Promise<void> {
|
||||
if(iconId <= 1000) {
|
||||
throw "invalid id!";
|
||||
}
|
||||
|
||||
await this.deleteFile({
|
||||
name: '/icon_' + (iconId >>> 0)
|
||||
});
|
||||
}
|
||||
|
||||
registeredTransfers() : FileTransfer[] {
|
||||
return this.registeredTransfers_.map(e => e.transfer);
|
||||
}
|
||||
|
|
189
shared/js/file/Icons.ts
Normal file
189
shared/js/file/Icons.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
import {Registry} from "tc-shared/events";
|
||||
|
||||
export const kIPCIconChannel = "avatars";
|
||||
export const kGlobalIconHandlerId = "global";
|
||||
|
||||
export interface RemoteIconEvents {
|
||||
notify_state_changed: { oldState: RemoteIconState, newState: RemoteIconState }
|
||||
}
|
||||
|
||||
export type RemoteIconState = "loading" | "loaded" | "error" | "empty" | "destroyed";
|
||||
|
||||
export type RemoteIconInfo = {
|
||||
iconId: number,
|
||||
serverUniqueId: string,
|
||||
handlerId?: string
|
||||
}
|
||||
|
||||
export abstract class RemoteIcon {
|
||||
readonly events: Registry<RemoteIconEvents>;
|
||||
readonly iconId: number;
|
||||
readonly serverUniqueId: string;
|
||||
|
||||
private state: RemoteIconState;
|
||||
|
||||
private imageUrl: string;
|
||||
private errorMessage: string;
|
||||
|
||||
protected constructor(serverUniqueId: string, iconId: number) {
|
||||
this.events = new Registry<RemoteIconEvents>();
|
||||
this.iconId = iconId;
|
||||
this.serverUniqueId = serverUniqueId;
|
||||
|
||||
this.state = "loading";
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.setState("destroyed");
|
||||
this.events.destroy();
|
||||
this.imageUrl = undefined;
|
||||
}
|
||||
|
||||
getState() : RemoteIconState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
protected setState(state: RemoteIconState) {
|
||||
if(this.state === state) {
|
||||
return;
|
||||
} else if(this.state === "destroyed") {
|
||||
throw tr("remote icon has been destroyed");
|
||||
}
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = state;
|
||||
this.events.fire("notify_state_changed", { newState: state, oldState: oldState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Will throw an string if the icon isn't in loaded state
|
||||
*/
|
||||
getImageUrl() : string {
|
||||
if(this.state !== "loaded") {
|
||||
throw tr("icon image url is only available when the state is loaded");
|
||||
}
|
||||
|
||||
if(!this.imageUrl) {
|
||||
throw tr("remote icon is missing an image url");
|
||||
}
|
||||
|
||||
return this.imageUrl;
|
||||
}
|
||||
|
||||
protected setImageUrl(url: string) {
|
||||
if(this.imageUrl) {
|
||||
throw tr("an image url has already been set");
|
||||
}
|
||||
|
||||
this.imageUrl = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Will throw an string if the state isn't error
|
||||
*/
|
||||
getErrorMessage() : string | undefined {
|
||||
if(this.state !== "error") {
|
||||
throw tr("invalid remote icon state, expected error");
|
||||
}
|
||||
|
||||
return this.errorMessage;
|
||||
}
|
||||
|
||||
protected setErrorMessage(message: string) {
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits 'till the icon has been loaded or any other, non loading, state has been reached.
|
||||
*/
|
||||
async awaitLoaded() {
|
||||
while(!this.isLoaded()) {
|
||||
await new Promise(resolve => this.events.one("notify_state_changed", resolve));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the icon isn't loading any more.
|
||||
* This includes all other states like error, destroy or empty.
|
||||
*/
|
||||
isLoaded() : boolean {
|
||||
return this.state !== "loading";
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class AbstractIconManager {
|
||||
/**
|
||||
* @param iconId The requested icon
|
||||
* @param serverUniqueId The server unique id for the icon
|
||||
* @param handlerId Hint which connection handler should be used if we're downloading the icon
|
||||
*/
|
||||
abstract resolveIcon(iconId: number, serverUniqueId: string, handlerId?: string) : RemoteIcon;
|
||||
}
|
||||
|
||||
let globalIconManager: AbstractIconManager;
|
||||
export function setIconManager(instance: AbstractIconManager) {
|
||||
if(globalIconManager) {
|
||||
throw "the global icon manager has already been set";
|
||||
}
|
||||
|
||||
globalIconManager = instance;
|
||||
}
|
||||
|
||||
export function getIconManager() {
|
||||
return globalIconManager;
|
||||
}
|
||||
|
||||
|
||||
/* a helper for legacy code */
|
||||
export function generateIconJQueryTag(icon: RemoteIcon | undefined, options?: { animate?: boolean }) : JQuery<HTMLDivElement> {
|
||||
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.iconId == 0) {
|
||||
icon_load_image = undefined;
|
||||
} else if (icon.iconId < 1000) {
|
||||
icon_load_image = undefined;
|
||||
icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.iconId);
|
||||
} else {
|
||||
const loading_done = sync => {//TODO: Show error?
|
||||
if (icon.getState() === "empty") {
|
||||
icon_load_image.remove();
|
||||
icon_load_image = undefined;
|
||||
} else if (icon.getState() === "error") {
|
||||
//TODO: Error icon?
|
||||
icon_load_image.remove();
|
||||
icon_load_image = undefined;
|
||||
} else {
|
||||
icon_image.attr("src", icon.getImageUrl());
|
||||
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.isLoaded()) {
|
||||
loading_done(true);
|
||||
} else {
|
||||
icon.awaitLoaded().then(() => loading_done(false));
|
||||
}
|
||||
}
|
||||
|
||||
if (icon_load_image) {
|
||||
icon_load_image.appendTo(icon_container);
|
||||
}
|
||||
return icon_container;
|
||||
}
|
|
@ -1,412 +0,0 @@
|
|||
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} 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 {tr} from "tc-shared/i18n/localize";
|
||||
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
||||
import {server_connections} from "tc-shared/ConnectionManager";
|
||||
|
||||
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<Response>;
|
||||
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<IconManagerEvents>;
|
||||
private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
|
||||
|
||||
constructor(handle: FileManager) {
|
||||
this.handle = handle;
|
||||
this.events = new Registry<IconManagerEvents>();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.loading_timestamps = {};
|
||||
}
|
||||
|
||||
async delete_icon(id: number) : Promise<void> {
|
||||
if(id <= 1000)
|
||||
throw "invalid id!";
|
||||
|
||||
await this.handle.deleteFile({
|
||||
name: '/icon_' + id
|
||||
});
|
||||
}
|
||||
|
||||
iconList() : Promise<FileInfo[]> {
|
||||
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<Response> {
|
||||
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 === ErrorCode.FILE_NOT_FOUND)
|
||||
throw tr("Icon could not be found");
|
||||
else if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS)
|
||||
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<HTMLDivElement> {
|
||||
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<HTMLDivElement> {
|
||||
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 = {};
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ export enum ImageType {
|
|||
JPEG
|
||||
}
|
||||
|
||||
export function media_image_type(type: ImageType, file?: boolean) {
|
||||
export function imageType2MediaType(type: ImageType, file?: boolean) {
|
||||
switch (type) {
|
||||
case ImageType.BITMAP:
|
||||
return "bmp";
|
||||
|
@ -26,7 +26,7 @@ export function media_image_type(type: ImageType, file?: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
export function image_type(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) {
|
||||
export function responseImageType(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) {
|
||||
const ab2str10 = () => {
|
||||
const buf = new Uint8Array(encoded_data as ArrayBuffer);
|
||||
if(buf.byteLength < 10)
|
||||
|
@ -97,7 +97,7 @@ export class ImageCache {
|
|||
/* FIXME: TODO */
|
||||
}
|
||||
|
||||
async resolve_cached(key: string, max_age?: number) : Promise<Response | undefined> {
|
||||
async resolveCached(key: string, max_age?: number) : Promise<Response | undefined> {
|
||||
max_age = typeof(max_age) === "number" ? max_age : -1;
|
||||
|
||||
const cached_response = await this._cache_category.match("https://_local_cache/cache_request_" + key);
|
||||
|
@ -108,7 +108,7 @@ export class ImageCache {
|
|||
return cached_response;
|
||||
}
|
||||
|
||||
async put_cache(key: string, value: Response, type?: string, headers?: {[key: string]:string}) {
|
||||
async putCache(key: string, value: Response, type?: string, headers?: {[key: string]:string}) {
|
||||
const new_headers = new Headers();
|
||||
for(const key of value.headers.keys())
|
||||
new_headers.set(key, value.headers.get(key));
|
||||
|
|
|
@ -4,7 +4,7 @@ import * as ipc from "../ipc/BrowserIPC";
|
|||
import {ChannelMessage} from "../ipc/BrowserIPC";
|
||||
import * as loader from "tc-loader";
|
||||
import {Stage} from "tc-loader";
|
||||
import {image_type, ImageCache, media_image_type} from "../file/ImageCache";
|
||||
import {responseImageType, ImageCache, imageType2MediaType} from "../file/ImageCache";
|
||||
import {FileManager} from "../file/FileManager";
|
||||
import {
|
||||
FileDownloadTransfer,
|
||||
|
@ -43,17 +43,18 @@ class LocalClientAvatar extends ClientAvatar {
|
|||
}
|
||||
|
||||
export class AvatarManager extends AbstractAvatarManager {
|
||||
handle: FileManager;
|
||||
private static cache: ImageCache;
|
||||
|
||||
|
||||
readonly handle: FileManager;
|
||||
private cachedAvatars: {[avatarId: string]: LocalClientAvatar} = {};
|
||||
|
||||
constructor(handle: FileManager) {
|
||||
super();
|
||||
this.handle = handle;
|
||||
|
||||
if(!AvatarManager.cache)
|
||||
if(!AvatarManager.cache) {
|
||||
AvatarManager.cache = new ImageCache("avatars");
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
@ -86,7 +87,7 @@ export class AvatarManager extends AbstractAvatarManager {
|
|||
await AvatarManager.cache.setup();
|
||||
}
|
||||
|
||||
const response = await AvatarManager.cache.resolve_cached('avatar_' + avatar.clientAvatarId); //TODO age!
|
||||
const response = await AvatarManager.cache.resolveCached('avatar_' + avatar.clientAvatarId); //TODO age!
|
||||
if(!response) {
|
||||
break cache_lookup;
|
||||
}
|
||||
|
@ -165,15 +166,15 @@ export class AvatarManager extends AbstractAvatarManager {
|
|||
throw tr("Avatar response missing media bytes");
|
||||
}
|
||||
|
||||
const type = image_type(headers.get('X-media-bytes'));
|
||||
const media = media_image_type(type);
|
||||
const type = responseImageType(headers.get('X-media-bytes'));
|
||||
const media = imageType2MediaType(type);
|
||||
|
||||
if(avatar.getAvatarHash() !== initialAvatarHash) {
|
||||
log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), initialAvatarHash, avatar.getAvatarHash());
|
||||
return;
|
||||
}
|
||||
|
||||
await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, {
|
||||
await AvatarManager.cache.putCache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, {
|
||||
"X-avatar-version": avatar.getAvatarHash()
|
||||
});
|
||||
|
||||
|
@ -189,8 +190,8 @@ export class AvatarManager extends AbstractAvatarManager {
|
|||
if(!avatarResponse.headers.has('X-media-bytes'))
|
||||
throw "missing media bytes";
|
||||
|
||||
const type = image_type(avatarResponse.headers.get('X-media-bytes'));
|
||||
const media = media_image_type(type);
|
||||
const type = responseImageType(avatarResponse.headers.get('X-media-bytes'));
|
||||
const media = imageType2MediaType(type);
|
||||
|
||||
const blob = await avatarResponse.blob();
|
||||
|
||||
|
@ -240,7 +241,7 @@ export class AvatarManager extends AbstractAvatarManager {
|
|||
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.getAvatarHash(), clientAvatarHash);
|
||||
}
|
||||
|
||||
const response = await AvatarManager.cache.resolve_cached('avatar_' + clientAvatarId);
|
||||
const response = await AvatarManager.cache.resolveCached('avatar_' + clientAvatarId);
|
||||
if(response) {
|
||||
let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
|
||||
if(cachedAvatarHash !== clientAvatarHash) {
|
||||
|
@ -353,7 +354,7 @@ export class AvatarManager extends AbstractAvatarManager {
|
|||
});
|
||||
};
|
||||
|
||||
/* FIXME: unsubscribe if the other client isn't alive any mnore */
|
||||
/* FIXME: unsubscribe if the other client isn't alive any anymore */
|
||||
class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory {
|
||||
private ipcChannel: IPCChannel;
|
||||
|
||||
|
|
274
shared/js/file/LocalIcons.ts
Normal file
274
shared/js/file/LocalIcons.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import * as loader from "tc-loader";
|
||||
import {Stage} from "tc-loader";
|
||||
import {ImageCache, ImageType, imageType2MediaType, responseImageType} from "tc-shared/file/ImageCache";
|
||||
import {AbstractIconManager, RemoteIcon, RemoteIconState, setIconManager} from "tc-shared/file/Icons";
|
||||
import * as log from "tc-shared/log";
|
||||
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
|
||||
import {server_connections} from "tc-shared/ConnectionManager";
|
||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||
import {FileTransferState, ResponseTransferTarget, TransferProvider, TransferTargetType} from "tc-shared/file/Transfer";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
||||
|
||||
/* TODO: Retry icon download after some time */
|
||||
/* TODO: Download icon when we're connected to the server were we want the icon from and update the icon */
|
||||
|
||||
async function responseToImageUrl(response: Response) : Promise<string> {
|
||||
if(!response.headers.has('X-media-bytes')) {
|
||||
throw "missing media bytes";
|
||||
}
|
||||
|
||||
const type = responseImageType(response.headers.get('X-media-bytes'));
|
||||
if(type === ImageType.UNKNOWN) {
|
||||
throw "unknown image type";
|
||||
}
|
||||
|
||||
const media = imageType2MediaType(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 LocalRemoteIcon extends RemoteIcon {
|
||||
constructor(serverUniqueId: string, iconId: number) {
|
||||
super(serverUniqueId, iconId);
|
||||
}
|
||||
|
||||
public setImageUrl(url: string) {
|
||||
super.setImageUrl(url);
|
||||
}
|
||||
|
||||
public setErrorMessage(message: string) {
|
||||
super.setErrorMessage(message);
|
||||
}
|
||||
|
||||
public setState(state: RemoteIconState) {
|
||||
super.setState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export let localIconCache: ImageCache;
|
||||
class IconManager extends AbstractIconManager {
|
||||
private cachedIcons: {[key: string]: LocalRemoteIcon} = {};
|
||||
|
||||
private static iconUniqueKey(iconId: number, serverUniqueId: string) : string {
|
||||
return "v2-" + serverUniqueId + "-" + iconId;
|
||||
}
|
||||
|
||||
resolveIcon(iconId: number, serverUniqueId: string, handlerIdHint: string): RemoteIcon {
|
||||
/* just to ensure */
|
||||
iconId = iconId >>> 0;
|
||||
|
||||
const iconUniqueId = IconManager.iconUniqueKey(iconId, serverUniqueId);
|
||||
if(this.cachedIcons[iconUniqueId]) {
|
||||
return this.cachedIcons[iconUniqueId];
|
||||
}
|
||||
|
||||
let icon = new LocalRemoteIcon(serverUniqueId, iconId);
|
||||
this.cachedIcons[iconUniqueId] = icon;
|
||||
|
||||
if(iconId >= 0 && iconId <= 1000) {
|
||||
icon.setState("loaded");
|
||||
} else {
|
||||
this.loadIcon(icon, iconUniqueId, handlerIdHint).catch(error => {
|
||||
if(typeof error !== "string") {
|
||||
logError(LogCategory.FILE_TRANSFER, tr("Failed to load icon %d (%s): %o"), iconId, serverUniqueId, error);
|
||||
icon.setErrorMessage(tr("load error, lookup the console"));
|
||||
} else{
|
||||
icon.setErrorMessage(error);
|
||||
icon.setState("error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private async loadIcon(icon: LocalRemoteIcon, iconUniqueId: string, handlerIdHint: string) {
|
||||
/* try to load the icon from the local cache */
|
||||
const localCache = await localIconCache.resolveCached(iconUniqueId);
|
||||
if(localCache) {
|
||||
try {
|
||||
const url = await responseToImageUrl(localCache);
|
||||
icon.setImageUrl(url);
|
||||
icon.setState("loaded");
|
||||
logDebug(LogCategory.FILE_TRANSFER, tr("Loaded icon %d (%s) from local cache."), icon.iconId, icon.serverUniqueId);
|
||||
return;
|
||||
} catch (error) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Failed to decode locally cached icon %d (%s): %o. Invalidating cache."), icon.iconId, icon.serverUniqueId, error);
|
||||
try {
|
||||
await localIconCache.delete(iconUniqueId);
|
||||
} catch (error) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Failed to delete invalid key from icon cache (%s): %o"), iconUniqueId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* try to fetch the icon from the server, if we're connected */
|
||||
let handler = server_connections.findConnection(handlerIdHint);
|
||||
if(handler) {
|
||||
if(!handler.connected) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Received handler id hint for icon download, but handler %s is not connected. Trying others."), handlerIdHint);
|
||||
handler = undefined;
|
||||
} else if(handler.channelTree.server.properties.virtualserver_unique_identifier !== icon.serverUniqueId) {
|
||||
logWarn(LogCategory.FILE_TRANSFER,
|
||||
tr("Received handler id hint for icon download, but handler %s is not connected to the expected server (%s <=> %s). Trying others."),
|
||||
handlerIdHint, handler.channelTree.server.properties.virtualserver_unique_identifier, icon.serverUniqueId);
|
||||
handler = undefined;
|
||||
} else {
|
||||
logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally but the suggested handler is connected to the server. Downloading icon."), icon.iconId, icon.serverUniqueId);
|
||||
}
|
||||
|
||||
/* we don't want any "handler not found" warning */
|
||||
handlerIdHint = undefined;
|
||||
}
|
||||
|
||||
if(!handler) {
|
||||
if(handlerIdHint) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Received handler id hint for icon download, but handler %s does not exists. Trying others."), handlerIdHint);
|
||||
}
|
||||
|
||||
const connections = server_connections.all_connections()
|
||||
.filter(handler => handler.connected)
|
||||
.filter(handler => handler.channelTree.server.properties.virtualserver_unique_identifier === icon.serverUniqueId);
|
||||
|
||||
if(connections.length === 0) {
|
||||
logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally and we're currently not connected to the target server. Returning an empty result."), icon.iconId, icon.serverUniqueId);
|
||||
icon.setState("empty");
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally but we're connected to the server, using first available connection (%s). Downloading icon."), icon.iconId, icon.serverUniqueId, connections[0].handlerId);
|
||||
handler = connections[0];
|
||||
}
|
||||
|
||||
try {
|
||||
await this.downloadIcon(icon, handler, iconUniqueId);
|
||||
} catch (error) {
|
||||
if(typeof error !== "string") {
|
||||
logError(LogCategory.FILE_TRANSFER, tr("Failed to download icon %d (%s) from %s: %o"), icon.iconId, icon.serverUniqueId, handler.handlerId, error);
|
||||
error = tr("download failed, lookup the console");
|
||||
}
|
||||
|
||||
icon.setErrorMessage(error);
|
||||
icon.setState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
if(icon.getState() === "loading") {
|
||||
icon.setErrorMessage(tr("unexpected loading state"));
|
||||
icon.setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadIcon(icon: LocalRemoteIcon, handler: ConnectionHandler, iconUniqueId: string) {
|
||||
const transfer = handler.fileManager.initializeFileDownload({
|
||||
path: "",
|
||||
name: "/icon_" + icon.iconId,
|
||||
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
|
||||
});
|
||||
|
||||
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 === ErrorCode.FILE_NOT_FOUND) {
|
||||
throw tr("Icon could not be found");
|
||||
} else if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
||||
throw tr("No permissions to download icon");
|
||||
} else {
|
||||
throw error.extra_message || error.message;
|
||||
}
|
||||
}
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Could not request download for icon %d: %o"), icon.iconId, 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");
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await responseToImageUrl(response.getResponse().clone());
|
||||
icon.setImageUrl(url);
|
||||
icon.setState("loaded");
|
||||
} catch (error) {
|
||||
if(typeof error !== "string") {
|
||||
logError(LogCategory.FILE_TRANSFER, tr("Failed to convert downloaded icon %d (%s) into an url: %o"), icon.iconId, icon.serverUniqueId, error);
|
||||
error = tr("download failed, lookup the console");
|
||||
}
|
||||
|
||||
icon.setErrorMessage(error);
|
||||
icon.setState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = response.getResponse();
|
||||
if(!resp.headers.has('X-media-bytes')) {
|
||||
throw "missing media bytes";
|
||||
}
|
||||
|
||||
const type = responseImageType(resp.headers.get('X-media-bytes'));
|
||||
if(type === ImageType.UNKNOWN) {
|
||||
throw "unknown image type";
|
||||
}
|
||||
|
||||
const media = imageType2MediaType(type);
|
||||
await localIconCache.putCache(iconUniqueId, response.getResponse(), "image/" + media);
|
||||
} catch (error) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Failed to save icon %s (%s) into local icon cache: %o"), icon.iconId, icon.serverUniqueId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||
name: "icon init",
|
||||
priority: 60,
|
||||
function: async () => {
|
||||
localIconCache = new ImageCache("icons");
|
||||
await localIconCache.setup();
|
||||
|
||||
setIconManager(new IconManager());
|
||||
}
|
||||
});
|
||||
|
||||
//TODO!
|
||||
/*
|
||||
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();
|
||||
});
|
||||
};
|
||||
*/
|
Loading…
Add table
Reference in a new issue