Implement system icons

Closes #44
This commit is contained in:
Nils Maier 2019-08-31 10:03:17 +02:00
parent 0bf9e76441
commit 0a9155dcec
11 changed files with 269 additions and 8 deletions

161
lib/iconcache.ts Normal file
View File

@ -0,0 +1,161 @@
"use strict";
// License: MIT
import { downloads } from "./browser";
import { EventEmitter } from "../uikit/lib/events";
import { PromiseSerializer } from "./pserializer";
const VERSION = 1;
const STORE = "iconcache";
// eslint-disable-next-line no-magic-numbers
const CACHE_SIZES = [16, 32, 64, 127];
const BLACKLISTED = Object.freeze(new Set([
"",
"ext",
"ico",
"pif",
"scr",
"ani",
"cur",
"ttf",
"otf",
"woff",
"woff2",
"cpl",
"desktop",
"app",
]));
async function getIcon(size: number, manId: number) {
const icon = new URL(await downloads.getFileIcon(manId, {size}));
if (icon.protocol === "data:") {
const res = await fetch(icon.toString());
const blob = await res.blob();
return {size, icon: blob};
}
return {size, icon};
}
const SYNONYMS = Object.freeze(new Map<string, string>([
["jpe", "jpg"],
["jpeg", "jpg"],
["jfif", "jpg"],
["mpe", "mpg"],
["mpeg", "mpg"],
["m4v", "mp4"],
]));
export const IconCache = new class IconCache extends EventEmitter {
private db: Promise<IDBDatabase>;
private cache: Map<string, string>;
constructor() {
super();
this.db = this.init();
this.cache = new Map();
this.get = PromiseSerializer.wrapNew(8, this, this.get);
this.set = PromiseSerializer.wrapNew(1, this, this.set);
}
private async init() {
return await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(STORE, VERSION);
req.onupgradeneeded = evt => {
const db = req.result;
switch (evt.oldVersion) {
case 0: {
db.createObjectStore(STORE);
break;
}
}
};
req.onerror = ex => reject(ex);
req.onsuccess = () => {
resolve(req.result);
};
});
}
private normalize(ext: string) {
ext = ext.toLocaleLowerCase();
return SYNONYMS.get(ext) || ext;
}
// eslint-disable-next-line no-magic-numbers
async get(ext: string, size = 16) {
ext = this.normalize(ext);
if (BLACKLISTED.has(ext)) {
return undefined;
}
const sext = `${ext}-${size}`;
let rv = this.cache.get(sext);
if (rv) {
return rv;
}
const db = await this.db;
rv = this.cache.get(sext);
if (rv) {
return rv;
}
return await new Promise<string | undefined>(resolve => {
const trans = db.transaction(STORE, "readonly");
trans.onerror = () => resolve(undefined);
const store = trans.objectStore(STORE);
const req = store.get(sext);
req.onerror = () => resolve(undefined);
req.onsuccess = () => {
const rv = this.cache.get(sext);
if (rv) {
resolve(rv);
return;
}
let {result} = req;
if (!result) {
resolve(undefined);
return;
}
if (typeof req.result !== "string") {
result = URL.createObjectURL(result).toString();
}
this.cache.set(sext, result);
this.cache.set(ext, "");
resolve(result);
};
});
}
async set(ext: string, manId: number) {
ext = this.normalize(ext);
if (BLACKLISTED.has(ext)) {
return;
}
if (this.cache.has(ext)) {
// already processed in this session
return;
}
// eslint-disable-next-line no-magic-numbers
const urls = await Promise.all(CACHE_SIZES.map(
size => getIcon(size, manId)));
if (this.cache.has(ext)) {
// already processed in this session
return;
}
for (const {size, icon} of urls) {
this.cache.set(`${ext}-${size}`, URL.createObjectURL(icon));
}
this.cache.set(ext, "");
const db = await this.db;
await new Promise((resolve, reject) => {
const trans = db.transaction(STORE, "readwrite");
trans.onerror = reject;
trans.oncomplete = resolve;
const store = trans.objectStore(STORE);
for (const {size, icon} of urls) {
store.put(icon, `${ext}-${size}`);
}
});
this.emit("cached", ext);
}
}();

View File

@ -153,6 +153,7 @@ export class BaseDownload {
rv.destPath = dest.path;
rv.destFull = dest.full;
rv.error = this.error;
rv.ext = this.renamer.p_ext;
return rv;
}
}

View File

@ -12,6 +12,7 @@ import { PromiseSerializer } from "../pserializer";
// eslint-disable-next-line no-unused-vars
import { Manager } from "./man";
import { downloads } from "../browser";
import { IconCache } from "../iconcache";
const setShelfEnabled = downloads.setShelfEnabled || function() {
@ -134,6 +135,9 @@ export class Download extends BaseDownload {
this.manager.addManId(
this.manId = await downloads.download(options), this);
}
await IconCache.
set(this.renamer.p_ext, this.manId).
catch(console.error);
}
finally {
setShelfEnabled(true);

View File

@ -5,6 +5,7 @@ import { donate, openPrefs } from "../windowutils";
import { API } from "../api";
// eslint-disable-next-line no-unused-vars
import { BaseDownload } from "./basedownload";
import { IconCache } from "../iconcache";
type SID = {sid: number};
type SIDS = {
@ -61,6 +62,11 @@ export class ManagerPort {
delete this.manager;
delete this.port;
});
IconCache.on("cached", ext => {
this.port.post("icon-cached", ext);
});
this.port.post("active", this.manager.active);
this.sendAll();
}

View File

@ -9,7 +9,7 @@
"default_locale": "en",
"content_security_policy": "script-src 'self'; style-src 'self' 'unsafe-inline'; default-src 'self'",
"content_security_policy": "script-src 'self'; style-src 'self' 'unsafe-inline'; img-src data: blob: 'self'; connect-src data: blob: 'self'; default-src 'self'",
"icons": {
"16": "style/icon16.png",

View File

@ -442,8 +442,13 @@ body > * {
}
#tooltip-icon {
font-size: 48px;
line-height: 48px;
height: 64px;
width: 64px;
background-size: 64px 64px;
background-repeat: no-repeat;
background-position: center center;
font-size: 64px;
line-height: 64px;
padding: 6px;
text-align: center;
grid-row: 1/-1;

View File

@ -19,7 +19,7 @@ export class Icons extends Map {
}
let cls = super.get(url);
if (!cls) {
cls = `icon-${++this.running}`;
cls = `iconcache-${++this.running}`;
const rule = `.${cls} { background-image: url(${url}); }`;
this.sheet.insertRule(rule);
super.set(url, cls);

View File

@ -93,6 +93,10 @@ addEventListener("DOMContentLoaded", function dom() {
Table.setItems(items);
});
});
PORT.on("icon-cached", async () => {
await fullyloaded;
Table.onIconCached();
});
// Updates
const serializer = new PromiseSerializer(1);

View File

@ -678,6 +678,11 @@ export class FilteredCollection extends EventEmitter {
this.emit("sorted");
}
}
invalidateIcons() {
this.items.forEach(item => item.clearFontIcons());
this.recalculate();
}
}
module.exports = {

View File

@ -37,6 +37,7 @@ import { downloads } from "../../lib/browser";
import { $ } from "../winutil";
// eslint-disable-next-line no-unused-vars
import { TableConfig } from "../../uikit/lib/config";
import { IconCache } from "../../lib/iconcache";
const TREE_CONFIG_VERSION = 2;
const RUNNING_TIMEOUT = 1000;
@ -52,7 +53,14 @@ const COL_SPEED = 6;
const COL_MASK = 7;
const COL_SEGS = 8;
const HIDPI = window.matchMedia &&
window.matchMedia("(min-resolution: 2dppx)").matches;
const ICON_BASE_SIZE = 16;
const ICON_REAL_SIZE = HIDPI ? ICON_BASE_SIZE * 2 : ICON_BASE_SIZE;
const LARGE_ICON_BASE_SIZE = 64;
const MAX_ICON_BASE_SIZE = 127;
const LARGE_ICON_REAL_SIZE = HIDPI ? MAX_ICON_BASE_SIZE : LARGE_ICON_BASE_SIZE;
let TEXT_SIZE_UNKNOWM = "unknown";
let REAL_STATE_TEXTS = Object.freeze(new Map<number, string>());
@ -108,6 +116,8 @@ export class DownloadItem extends EventEmitter {
public finalName: string;
public ext?: string;
public position: number;
public filteredPosition: number;
@ -128,6 +138,10 @@ export class DownloadItem extends EventEmitter {
public mask: string;
private iconField?: string;
private largeIconField?: string;
constructor(owner: DownloadTable, raw: any, stats?: Stats) {
super();
Object.assign(this, raw);
@ -138,6 +152,42 @@ export class DownloadItem extends EventEmitter {
this.lastWritten = 0;
}
get icon() {
if (this.iconField) {
return this.iconField;
}
this.iconField = this.owner.icons.get(
iconForPath(this.finalName, ICON_BASE_SIZE));
if (this.ext) {
IconCache.get(this.ext, ICON_REAL_SIZE).then(icon => {
if (icon) {
this.iconField = this.owner.icons.get(icon);
if (typeof this.filteredPosition !== undefined) {
this.owner.invalidateCell(this.filteredPosition, COL_URL);
}
}
});
}
return this.iconField || "";
}
get largeIcon() {
if (this.largeIconField) {
return this.largeIconField;
}
this.largeIconField = this.owner.icons.get(
iconForPath(this.finalName, LARGE_ICON_BASE_SIZE));
if (this.ext) {
IconCache.get(this.ext, LARGE_ICON_REAL_SIZE).then(icon => {
if (icon) {
this.largeIconField = this.owner.icons.get(icon);
}
this.emit("largeIcon");
});
}
return this.largeIconField || "";
}
get eta() {
const {avg} = this.stats;
if (!this.totalSize || !avg) {
@ -215,6 +265,9 @@ export class DownloadItem extends EventEmitter {
PORT.post("all");
return;
}
if (("ext" in raw) && raw.ext !== this.ext) {
this.clearIcons();
}
delete raw.position;
delete raw.owner;
const oldState = this.state;
@ -297,6 +350,20 @@ export class DownloadItem extends EventEmitter {
this.domain = this.uURL.domain;
this.emit("url");
}
clearIcons() {
this.iconField = undefined;
this.largeIconField = undefined;
}
clearFontIcons() {
if (this.iconField && this.iconField.startsWith("icon-")) {
this.iconField = undefined;
}
if (this.largeIconField && this.largeIconField.startsWith("icon-")) {
this.largeIconField = undefined;
}
}
}
@ -323,7 +390,7 @@ export class DownloadTable extends VirtualTable {
private readonly sids: Map<number, DownloadItem>;
private readonly icons: Icons;
public readonly icons: Icons;
private readonly contextMenu: ContextMenu;
@ -357,6 +424,7 @@ export class DownloadTable extends VirtualTable {
this.showUrls = new ShowUrlsWatcher(this);
this.updateCounts = debounce(this.updateCounts.bind(this), 100);
this.onIconCached = debounce(this.onIconCached.bind(this), 1000);
this.downloads = new FilteredCollection(this);
this.downloads.on("changed", () => this.updateCounts());
@ -1043,10 +1111,10 @@ export class DownloadTable extends VirtualTable {
}
const item = this.downloads.filtered[rowid];
if (colid === COL_URL) {
return this.icons.get(iconForPath(item.finalName, ICON_BASE_SIZE));
return item.icon;
}
if (colid === COL_PROGRESS) {
return StateIcons.get(item.state);
return StateIcons.get(item.state) || null;
}
return null;
}
@ -1119,4 +1187,8 @@ export class DownloadTable extends VirtualTable {
return -1;
}
}
onIconCached() {
this.downloads.invalidateIcons();
}
}

View File

@ -146,6 +146,8 @@ export class Tooltip {
constructor(item: DownloadItem, pos: number) {
this.update = this.update.bind(this);
this.item = item;
this.item.on("largeIcon", this.update);
const tmpl = (
document.querySelector<HTMLTemplateElement>("#tooltip-template"));
if (!tmpl) {
@ -178,7 +180,7 @@ export class Tooltip {
this.dismiss();
return;
}
const icon = item.owner.getCellIcon(item.filteredPosition, 0);
const icon = item.largeIcon;
this.icon.className = icon;
this.name.textContent = item.destFull;
this.from.textContent = item.usable;
@ -318,5 +320,6 @@ export class Tooltip {
}
this.item.off("stats", this.update);
this.item.off("update", this.update);
this.item.off("largeIcon", this.update);
}
}