Icon resizing of viewPort dynamically, ability to specify custom fore and background colors via env vars (#32)

This commit is contained in:
Simon J
2021-08-26 15:18:15 +10:00
committed by GitHub
parent b900863c78
commit d1f00f549c
26 changed files with 930 additions and 200 deletions

View File

@@ -64,6 +64,7 @@ const app = server(
new InMemoryLinkCodes(),
new InMemoryAccessTokens(sha256(config.secret)),
SystemClock,
config.icons,
true,
);

View File

@@ -1,5 +1,10 @@
import dayjs, { Dayjs } from "dayjs";
export const isChristmas = (clock: Clock = SystemClock) => clock.now().month() == 11 && clock.now().date() == 25;
export const isHalloween = (clock: Clock = SystemClock) => clock.now().month() == 9 && clock.now().date() == 31
export const isHoli = (clock: Clock = SystemClock) => ["2022/03/18", "2023/03/07", "2024/03/25", "2025/03/14"].map(dayjs).find(it => it.isSame(clock.now())) != undefined
export const isCNY = (clock: Clock = SystemClock) => ["2022/02/01", "2023/01/22", "2024/02/10", "2025/02/29"].map(dayjs).find(it => it.isSame(clock.now())) != undefined
export interface Clock {
now(): Dayjs;
}

View File

@@ -16,10 +16,24 @@ export default function () {
process.exit(1);
}
const colorFrom = (envVar: string) => {
const value = process.env[envVar];
if (value && value != "") {
if (value.match(/^\w+$/)) return value;
else throw `Invalid color specified for ${envVar}`;
} else {
return undefined;
}
};
return {
port,
bonobUrl: url(bonobUrl),
secret: process.env["BONOB_SECRET"] || "bonob",
icons: {
foregroundColor: colorFrom("BONOB_ICON_FOREGROUND_COLOR"),
backgroundColor: colorFrom("BONOB_ICON_BACKGROUND_COLOR"),
},
sonos: {
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
deviceDiscovery:

154
src/icon.ts Normal file
View File

@@ -0,0 +1,154 @@
import libxmljs, { Element, Attribute } from "libxmljs2";
import _ from "underscore";
import { Clock, isChristmas, isCNY, isHalloween, isHoli, SystemClock } from "./clock";
export type Transformation = {
viewPortIncreasePercent: number | undefined;
backgroundColor: string | undefined;
foregroundColor: string | undefined;
};
const SVG_NS = {
svg: "http://www.w3.org/2000/svg",
};
class ViewBox {
minX: number;
minY: number;
width: number;
height: number;
constructor(viewBox: string) {
const parts = viewBox.split(" ").map((it) => Number.parseInt(it));
this.minX = parts[0]!;
this.minY = parts[1]!;
this.width = parts[2]!;
this.height = parts[3]!;
}
public increasePercent = (percent: number) => {
const i = Math.floor(((percent / 100) * this.height) / 3);
return new ViewBox(
`${-i} ${-i} ${this.height + 2 * i} ${this.height + 2 * i}`
);
};
public toString = () =>
`${this.minX} ${this.minY} ${this.width} ${this.height}`;
}
export interface Icon {
with(newTransformation: Partial<Transformation>): Icon;
}
export class ColorOverridingIcon implements Icon {
rule: () => Boolean;
newColors: () => Pick<Transformation, "backgroundColor" | "foregroundColor">;
icon: Icon;
constructor(
icon: Icon,
rule: () => Boolean,
newColors: () => Pick<Transformation, "backgroundColor" | "foregroundColor">
) {
this.icon = icon;
this.rule = rule;
this.newColors = newColors;
}
public with = (transformation: Partial<Transformation>) =>
this.rule()
? this.icon.with({ ...transformation, ...this.newColors() })
: this.icon.with(transformation);
public toString = () => this.with({}).toString();
}
export class SvgIcon implements Icon {
private svg: string;
private transformation: Transformation;
constructor(
svg: string,
transformation: Transformation = {
viewPortIncreasePercent: undefined,
backgroundColor: undefined,
foregroundColor: undefined,
}
) {
this.svg = svg;
this.transformation = transformation;
}
public with = (newTransformation: Partial<Transformation>) =>
new SvgIcon(this.svg, { ...this.transformation, ...newTransformation });
public toString = () => {
const xml = libxmljs.parseXmlString(this.svg, {
noblanks: true,
net: false,
});
const viewBoxAttr = xml.get("//svg:svg/@viewBox", SVG_NS) as Attribute;
let viewBox = new ViewBox(viewBoxAttr.value());
if (
this.transformation.viewPortIncreasePercent &&
this.transformation.viewPortIncreasePercent > 0
) {
viewBox = viewBox.increasePercent(
this.transformation.viewPortIncreasePercent
);
viewBoxAttr.value(viewBox.toString());
}
if (this.transformation.backgroundColor) {
(xml.get("//svg:svg/*[1]", SVG_NS) as Element).addPrevSibling(
new Element(xml, "rect").attr({
x: `${viewBox.minX}`,
y: `${viewBox.minY}`,
width: `${Math.abs(viewBox.minX) + viewBox.width}`,
height: `${Math.abs(viewBox.minY) + viewBox.height}`,
style: `fill:${this.transformation.backgroundColor}`,
})
);
}
if (this.transformation.foregroundColor) {
(xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) =>
path.attr({ style: `fill:${this.transformation.foregroundColor}` })
);
}
return xml.toString();
};
}
export const HOLI_COLORS = ["#06bceb", "#9fc717", "#fbdc10", "#f00b9a", "#fa9705"]
export const makeFestive = (icon: Icon, clock: Clock = SystemClock): Icon => {
const wrap = (
icon: Icon,
rule: (clock: Clock) => boolean,
colors: Pick<Transformation, "backgroundColor" | "foregroundColor">
) =>
new ColorOverridingIcon(
icon,
() => rule(clock),
() => colors
);
const xmas = wrap(icon, isChristmas, {
backgroundColor: "green",
foregroundColor: "red",
});
const randomHoliColors = _.shuffle([...HOLI_COLORS]);
const holi = wrap(xmas, isHoli, {
backgroundColor: randomHoliColors.pop(),
foregroundColor: randomHoliColors.pop(),
});
const cny = wrap(holi, isCNY, {
backgroundColor: "red",
foregroundColor: "yellow",
});
const halloween = wrap(cny, isHalloween, {
backgroundColor: "orange",
foregroundColor: "black",
});
return halloween;
};

View File

@@ -3,7 +3,6 @@ import express, { Express, Request } from "express";
import * as Eta from "eta";
import morgan from "morgan";
import path from "path";
import scale from "scale-that-svg";
import sharp from "sharp";
import fs from "fs";
@@ -29,29 +28,10 @@ import { Clock, SystemClock } from "./clock";
import { pipe } from "fp-ts/lib/function";
import { URLBuilder } from "./url_builder";
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
import { SvgIcon, Icon, makeFestive } from "./icon";
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
const icon = (name: string) =>
fs
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
.toString();
export type Icon = { svg: string; size: number };
export const ICONS: Record<ICON, Icon> = {
artists: { svg: icon("navidrome-artists.svg"), size: 24 },
albums: { svg: icon("navidrome-all.svg"), size: 24 },
playlists: { svg: icon("navidrome-playlists.svg"), size: 24 },
genres: { svg: icon("Theatre-Mask-111172.svg"), size: 128 },
random: { svg: icon("navidrome-random.svg"), size: 24 },
starred: { svg: icon("navidrome-topRated.svg"), size: 24 },
recentlyAdded: { svg: icon("navidrome-recentlyAdded.svg"), size: 24 },
recentlyPlayed: { svg: icon("navidrome-recentlyPlayed.svg"), size: 24 },
mostPlayed: { svg: icon("navidrome-mostPlayed.svg"), size: 24 },
discover: { svg: icon("Binoculars-14310.svg"), size: 32 },
};
interface RangeFilter extends Transform {
range: (length: number) => string;
}
@@ -97,6 +77,10 @@ function server(
linkCodes: LinkCodes = new InMemoryLinkCodes(),
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
clock: Clock = SystemClock,
iconColors: {
foregroundColor: string | undefined;
backgroundColor: string | undefined;
} = { foregroundColor: undefined, backgroundColor: undefined },
applyContextPath = true
): Express {
const app = express();
@@ -119,6 +103,27 @@ function server(
return i8n(...asLANGs(req.headers["accept-language"]));
};
const iconFrom = (name: string) =>
makeFestive(
new SvgIcon(
fs.readFileSync(path.resolve(__dirname, "..", "web", "icons", name)).toString()
).with({ viewPortIncreasePercent: 50, ...iconColors }),
clock
);
const ICONS: Record<ICON, Icon> = {
artists: iconFrom("navidrome-artists.svg"),
albums: iconFrom("navidrome-all.svg"),
playlists: iconFrom("navidrome-playlists.svg"),
genres: iconFrom("Theatre-Mask-111172.svg"),
random: iconFrom("navidrome-random.svg"),
starred: iconFrom("navidrome-topRated.svg"),
recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"),
recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"),
mostPlayed: iconFrom("navidrome-mostPlayed.svg"),
discover: iconFrom("Binoculars-14310.svg"),
};
app.get("/", (req, res) => {
const lang = langFor(req);
Promise.all([sonos.devices(), sonos.services()]).then(
@@ -391,20 +396,17 @@ function server(
const spec =
size == "legacy"
? {
outputSize: 80,
mimeType: "image/png",
responseFormatter: (svg: string): Promise<Buffer | string> =>
sharp(Buffer.from(svg)).png().toBuffer(),
sharp(Buffer.from(svg)).resize(80).png().toBuffer(),
}
: {
outputSize: Number.parseInt(size),
mimeType: "image/svg+xml",
responseFormatter: (svg: string): Promise<Buffer | string> =>
Promise.resolve(svg),
};
return Promise.resolve(icon.svg)
.then((svg) => scale(svg, { scale: spec.outputSize / icon.size }))
return Promise.resolve(icon.toString())
.then(spec.responseFormatter)
.then((data) => res.status(200).type(spec.mimeType).send(data));
}
@@ -437,9 +439,9 @@ function server(
}
})
.catch((e: Error) => {
logger.error(
`Failed fetching image ${type}/${id}/size/${size}`, { cause: e }
);
logger.error(`Failed fetching image ${type}/${id}/size/${size}`, {
cause: e,
});
return res.status(500).send();
});
}