mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Icon resizing of viewPort dynamically, ability to specify custom fore and background colors via env vars (#32)
This commit is contained in:
@@ -64,6 +64,7 @@ const app = server(
|
||||
new InMemoryLinkCodes(),
|
||||
new InMemoryAccessTokens(sha256(config.secret)),
|
||||
SystemClock,
|
||||
config.icons,
|
||||
true,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
154
src/icon.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user