From d1f00f549c2432da21eaf3caab61fcc5fad3e12d Mon Sep 17 00:00:00 2001 From: Simon J Date: Thu, 26 Aug 2021 15:18:15 +1000 Subject: [PATCH] Icon resizing of viewPort dynamically, ability to specify custom fore and background colors via env vars (#32) --- README.md | 2 + nodemon.json | 5 + package.json | 6 +- src/app.ts | 1 + src/clock.ts | 5 + src/config.ts | 14 + src/icon.ts | 154 +++++++++ src/server.ts | 60 ++-- tests/clock.test.ts | 58 ++++ tests/config.test.ts | 60 ++++ tests/icon.test.ts | 429 +++++++++++++++++++++++++ tests/server.test.ts | 75 +++-- web/icons/Binoculars-14310.svg | 5 +- web/icons/Theatre-Mask-111172.svg | 6 +- web/icons/index.html | 33 ++ web/icons/navidrome-all.svg | 2 +- web/icons/navidrome-artists.svg | 4 +- web/icons/navidrome-favourites.svg | 2 +- web/icons/navidrome-mostPlayed.svg | 3 +- web/icons/navidrome-playlists.svg | 2 +- web/icons/navidrome-random.svg | 3 +- web/icons/navidrome-recentlyAdded.svg | 2 +- web/icons/navidrome-recentlyPlayed.svg | 2 +- web/icons/navidrome-songs.svg | 2 +- web/icons/navidrome-topRated.svg | 2 +- yarn.lock | 193 ++++------- 26 files changed, 930 insertions(+), 200 deletions(-) create mode 100644 nodemon.json create mode 100644 src/icon.ts create mode 100644 tests/clock.test.ts create mode 100644 tests/icon.test.ts create mode 100644 web/icons/index.html diff --git a/README.md b/README.md index bd00610..b7e4b70 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ BONOB_NAVIDROME_URL | http://$(hostname):4533 | URL for navidrome BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom navidrome clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. BONOB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BONOB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing +BONOB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [web color](https://www.december.com/html/spec/colorsvg.html) +BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [web color](https://www.december.com/html/spec/colorsvg.html) ## Initialising service within sonos app diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..61ad1a8 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src/**/*.ts", "web/**/*.svg"], + "ignore": [], + "exec": "ts-node ./src/app.ts" +} diff --git a/package.json b/package.json index 3193daa..a166828 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "eta": "^1.12.1", "express": "^4.17.1", "fp-ts": "^2.9.5", + "libxmljs2": "^0.27.0", "morgan": "^1.10.0", "node-html-parser": "^2.1.0", - "scale-that-svg": "^1.0.5", "sharp": "^0.27.2", "soap": "^0.37.0", "ts-md5": "^1.2.7", @@ -50,8 +50,8 @@ "scripts": { "clean": "rm -Rf build", "build": "tsc", - "dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon ./src/app.ts", - "devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon ./src/app.ts", + "dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon", + "devr": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534", "test": "jest --testPathIgnorePatterns=build" } diff --git a/src/app.ts b/src/app.ts index a9a21f7..09cbd1c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -64,6 +64,7 @@ const app = server( new InMemoryLinkCodes(), new InMemoryAccessTokens(sha256(config.secret)), SystemClock, + config.icons, true, ); diff --git a/src/clock.ts b/src/clock.ts index adede94..f1066db 100644 --- a/src/clock.ts +++ b/src/clock.ts @@ -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; } diff --git a/src/config.ts b/src/config.ts index ff1c560..da2358c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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: diff --git a/src/icon.ts b/src/icon.ts new file mode 100644 index 0000000..b8e2db2 --- /dev/null +++ b/src/icon.ts @@ -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): Icon; +} + +export class ColorOverridingIcon implements Icon { + rule: () => Boolean; + newColors: () => Pick; + icon: Icon; + + constructor( + icon: Icon, + rule: () => Boolean, + newColors: () => Pick + ) { + this.icon = icon; + this.rule = rule; + this.newColors = newColors; + } + + public with = (transformation: Partial) => + 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) => + 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 + ) => + 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; +}; diff --git a/src/server.ts b/src/server.ts index f189e66..2f94386 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 = { - 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 = { + 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 => - 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 => 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(); }); } diff --git a/tests/clock.test.ts b/tests/clock.test.ts new file mode 100644 index 0000000..1d90ff3 --- /dev/null +++ b/tests/clock.test.ts @@ -0,0 +1,58 @@ +import dayjs from "dayjs"; +import { isChristmas, isCNY, isHalloween, isHoli } from "../src/clock"; + +describe("isChristmas", () => { + ["2000/12/25", "2022/12/25", "2030/12/25"].forEach((date) => { + it(`should return true for ${date} regardless of year`, () => { + expect(isChristmas({ now: () => dayjs(date) })).toEqual(true); + }); + }); + + ["2000/12/24", "2000/12/26", "2021/01/01"].forEach((date) => { + it(`should return false for ${date} regardless of year`, () => { + expect(isChristmas({ now: () => dayjs(date) })).toEqual(false); + }); + }); +}); + +describe("isHalloween", () => { + ["2000/10/31", "2022/10/31", "2030/10/31"].forEach((date) => { + it(`should return true for ${date} regardless of year`, () => { + expect(isHalloween({ now: () => dayjs(date) })).toEqual(true); + }); + }); + + ["2000/09/31", "2000/10/30", "2021/01/01"].forEach((date) => { + it(`should return false for ${date} regardless of year`, () => { + expect(isHalloween({ now: () => dayjs(date) })).toEqual(false); + }); + }); +}); + +describe("isHoli", () => { + ["2022/03/18", "2023/03/07", "2024/03/25", "2025/03/14"].forEach((date) => { + it(`should return true for ${date} regardless of year`, () => { + expect(isHoli({ now: () => dayjs(date) })).toEqual(true); + }); + }); + + ["2000/09/31", "2000/10/30", "2021/01/01"].forEach((date) => { + it(`should return false for ${date} regardless of year`, () => { + expect(isHoli({ now: () => dayjs(date) })).toEqual(false); + }); + }); +}); + +describe("isCNY", () => { + ["2022/02/01", "2023/01/22", "2024/02/10", "2025/02/29"].forEach((date) => { + it(`should return true for ${date} regardless of year`, () => { + expect(isCNY({ now: () => dayjs(date) })).toEqual(true); + }); + }); + + ["2000/09/31", "2000/10/30", "2021/01/01"].forEach((date) => { + it(`should return false for ${date} regardless of year`, () => { + expect(isCNY({ now: () => dayjs(date) })).toEqual(false); + }); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 755909d..2db7293 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -107,6 +107,66 @@ describe("config", () => { }); }); + describe("icons", () => { + describe("foregroundColor", () => { + describe("when BONOB_ICON_FOREGROUND_COLOR is not specified", () => { + it(`should default to undefined`, () => { + expect(config().icons.foregroundColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FOREGROUND_COLOR is ''", () => { + it(`should default to undefined`, () => { + process.env["BONOB_ICON_FOREGROUND_COLOR"] = ""; + expect(config().icons.foregroundColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_FOREGROUND_COLOR is specified", () => { + it(`should use it`, () => { + process.env["BONOB_ICON_FOREGROUND_COLOR"] = "pink"; + expect(config().icons.foregroundColor).toEqual("pink"); + }); + }); + + describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => { + it(`should blow up`, () => { + process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd"; + expect(() => config()).toThrow("Invalid color specified for BONOB_ICON_FOREGROUND_COLOR") + }); + }); + }); + + describe("backgroundColor", () => { + describe("when BONOB_ICON_BACKGROUND_COLOR is not specified", () => { + it(`should default to undefined`, () => { + expect(config().icons.backgroundColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_BACKGROUND_COLOR is ''", () => { + it(`should default to undefined`, () => { + process.env["BONOB_ICON_BACKGROUND_COLOR"] = ""; + expect(config().icons.backgroundColor).toEqual(undefined); + }); + }); + + describe("when BONOB_ICON_BACKGROUND_COLOR is specified", () => { + it(`should use it`, () => { + process.env["BONOB_ICON_BACKGROUND_COLOR"] = "blue"; + expect(config().icons.backgroundColor).toEqual("blue"); + }); + }); + + describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => { + it(`should blow up`, () => { + process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red"; + expect(() => config()).toThrow("Invalid color specified for BONOB_ICON_BACKGROUND_COLOR") + }); + }); + }); +}); + describe("secret", () => { it("should default to bonob", () => { expect(config().secret).toEqual("bonob"); diff --git a/tests/icon.test.ts b/tests/icon.test.ts new file mode 100644 index 0000000..1b293b2 --- /dev/null +++ b/tests/icon.test.ts @@ -0,0 +1,429 @@ +import dayjs from "dayjs"; +import libxmljs from "libxmljs2"; + +import { + ColorOverridingIcon, + HOLI_COLORS, + Icon, + makeFestive, + SvgIcon, + Transformation, +} from "../src/icon"; + +describe("SvgIcon", () => { + const xmlTidy = (xml: string) => + libxmljs.parseXmlString(xml, { noblanks: true, net: false }).toString(); + + const svgIcon24 = ` + + + + + +`; + + const svgIcon128 = ` + + + + + +`; + + describe("with no transformation", () => { + it("should be the same", () => { + expect(new SvgIcon(svgIcon24).toString()).toEqual(xmlTidy(svgIcon24)); + }); + }); + + describe("with a view port increase", () => { + describe("of 50%", () => { + describe("when the viewPort is of size 0 0 24 24", () => { + it("should resize the viewPort", () => { + expect( + new SvgIcon(svgIcon24) + .with({ viewPortIncreasePercent: 50 }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + describe("when the viewPort is of size 0 0 128 128", () => { + it("should resize the viewPort", () => { + expect( + new SvgIcon(svgIcon128) + .with({ viewPortIncreasePercent: 50 }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + }); + + describe("of 0%", () => { + it("should do nothing", () => { + expect( + new SvgIcon(svgIcon24).with({ viewPortIncreasePercent: 0 }).toString() + ).toEqual(xmlTidy(svgIcon24)); + }); + }); + }); + + describe("background color", () => { + describe("with no viewPort increase", () => { + it("should add a rectangle the same size as the original viewPort", () => { + expect( + new SvgIcon(svgIcon24).with({ backgroundColor: "red" }).toString() + ).toEqual( + xmlTidy(` + + + + + + + `) + ); + }); + }); + + describe("with a viewPort increase", () => { + it("should add a rectangle the same size as the original viewPort", () => { + expect( + new SvgIcon(svgIcon24) + .with({ backgroundColor: "pink", viewPortIncreasePercent: 50 }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + + `) + ); + }); + }); + + describe("of undefined", () => { + it("should not do anything", () => { + expect( + new SvgIcon(svgIcon24) + .with({ backgroundColor: undefined }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + + describe("multiple times", () => { + it("should use the most recent", () => { + expect( + new SvgIcon(svgIcon24) + .with({ backgroundColor: "green" }) + .with({ backgroundColor: "red" }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + + `) + ); + }); + }); + }); + + describe("foreground color", () => { + describe("with no viewPort increase", () => { + it("should add a rectangle the same size as the original viewPort", () => { + expect( + new SvgIcon(svgIcon24).with({ foregroundColor: "red" }).toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + + describe("with a viewPort increase", () => { + it("should add a rectangle the same size as the original viewPort", () => { + expect( + new SvgIcon(svgIcon24) + .with({ foregroundColor: "pink", viewPortIncreasePercent: 50 }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + + describe("of undefined", () => { + it("should not do anything", () => { + expect( + new SvgIcon(svgIcon24) + .with({ foregroundColor: undefined }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + + describe("mutliple times", () => { + it("should use the most recent", () => { + expect( + new SvgIcon(svgIcon24) + .with({ foregroundColor: "blue" }) + .with({ foregroundColor: "red" }) + .toString() + ).toEqual( + xmlTidy(` + + + + + + `) + ); + }); + }); + }); +}); + +class DummyIcon implements Icon { + transformation: Partial; + constructor(transformation: Partial) { + this.transformation = transformation; + } + public with = (newTransformation: Partial) => + new DummyIcon({ ...this.transformation, ...newTransformation }); + + public toString = () => JSON.stringify(this); +} + +describe("ColorOverridingIcon", () => { + describe("when the rule matches", () => { + const icon = new DummyIcon({ + backgroundColor: "black", + foregroundColor: "black", + }); + const overriding = new ColorOverridingIcon( + icon, + () => true, + () => ({ backgroundColor: "blue", foregroundColor: "red" }) + ); + + describe("with", () => { + it("should be the with of the underlieing icon with the overriden colors", () => { + const result = overriding.with({ + viewPortIncreasePercent: 99, + backgroundColor: "shouldBeIgnored", + foregroundColor: "shouldBeIgnored", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 99, + backgroundColor: "blue", + foregroundColor: "red", + }); + }); + }); + + describe("toString", () => { + it("should be the toString of the underlieing icon with the overriden colors", () => { + expect(overriding.toString()).toEqual( + new DummyIcon({ + backgroundColor: "blue", + foregroundColor: "red", + }).toString() + ); + }); + }); + }); + + describe("when the rule doesnt match", () => { + const icon = new DummyIcon({ + backgroundColor: "black", + foregroundColor: "black", + }); + const overriding = new ColorOverridingIcon( + icon, + () => false, + () => ({ backgroundColor: "blue", foregroundColor: "red" }) + ); + + describe("with", () => { + it("should use the provided transformation", () => { + const result = overriding.with({ + viewPortIncreasePercent: 88, + backgroundColor: "shouldBeUsed", + foregroundColor: "shouldBeUsed", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 88, + backgroundColor: "shouldBeUsed", + foregroundColor: "shouldBeUsed", + }); + }); + }); + + describe("toString", () => { + it("should be the toString of the unchanged icon", () => { + expect(overriding.toString()).toEqual(icon.toString()); + }); + }); + }); +}); + +describe("makeFestive", () => { + const icon = new DummyIcon({ + backgroundColor: "black", + foregroundColor: "black", + }); + let now = dayjs(); + + const festiveIcon = makeFestive(icon, { now: () => now }) + + describe("on a non special day", () => { + beforeEach(() => { + now = dayjs("2022/10/12"); + }); + + it("should use the given colors", () => { + const result = festiveIcon.with({ + viewPortIncreasePercent: 88, + backgroundColor: "shouldBeUsed", + foregroundColor: "shouldBeUsed", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 88, + backgroundColor: "shouldBeUsed", + foregroundColor: "shouldBeUsed", + }); + }); + }); + + describe("on christmas day", () => { + beforeEach(() => { + now = dayjs("2022/12/25"); + }); + + it("should use the given colors", () => { + const result = festiveIcon.with({ + viewPortIncreasePercent: 25, + backgroundColor: "shouldNotBeUsed", + foregroundColor: "shouldNotBeUsed", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 25, + backgroundColor: "green", + foregroundColor: "red", + }); + }); + }); + + describe("on halloween", () => { + beforeEach(() => { + now = dayjs("2022/10/31"); + }); + + it("should use the given colors", () => { + const result = festiveIcon.with({ + viewPortIncreasePercent: 12, + backgroundColor: "shouldNotBeUsed", + foregroundColor: "shouldNotBeUsed", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 12, + backgroundColor: "orange", + foregroundColor: "black", + }); + }); + }); + + describe("on cny", () => { + beforeEach(() => { + now = dayjs("2022/02/01"); + }); + + it("should use the given colors", () => { + const result = festiveIcon.with({ + viewPortIncreasePercent: 12, + backgroundColor: "shouldNotBeUsed", + foregroundColor: "shouldNotBeUsed", + }) as DummyIcon; + + expect(result.transformation).toEqual({ + viewPortIncreasePercent: 12, + backgroundColor: "red", + foregroundColor: "yellow", + }); + }); + }); + + describe("on holi", () => { + beforeEach(() => { + now = dayjs("2022/03/18"); + }); + + it("should use the given colors", () => { + const result = festiveIcon.with({ + viewPortIncreasePercent: 12, + backgroundColor: "shouldNotBeUsed", + foregroundColor: "shouldNotBeUsed", + }) as DummyIcon; + + expect(result.transformation.viewPortIncreasePercent).toEqual(12); + expect(HOLI_COLORS.includes(result.transformation.backgroundColor!)).toEqual(true); + expect(HOLI_COLORS.includes(result.transformation.foregroundColor!)).toEqual(true); + expect(result.transformation.backgroundColor).not.toEqual(result.transformation.foregroundColor); + }); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index f19e8fc..e3a0416 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -6,7 +6,6 @@ import Image from "image-js"; import { MusicService } from "../src/music_service"; import makeServer, { BONOB_ACCESS_TOKEN_HEADER, - ICONS, RangeBytesFromFilter, rangeFilterFor, } from "../src/server"; @@ -21,9 +20,8 @@ import { Response } from "express"; import { Transform } from "stream"; import url from "../src/url_builder"; import i8n, { randomLang } from "../src/i8n"; -import { - SONOS_RECOMMENDED_IMAGE_SIZES, -} from "../src/smapi"; +import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi"; +import { Clock, SystemClock } from "../src/clock"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -1264,14 +1262,23 @@ describe("server", () => { }); describe("/icon", () => { - const server = makeServer( - jest.fn() as unknown as Sonos, - aService(), - url("http://localhost:1234"), - jest.fn() as unknown as MusicService, - new InMemoryLinkCodes(), - jest.fn() as unknown as AccessTokens - ); + const server = ( + clock: Clock = SystemClock, + iconColors: { + foregroundColor: string | undefined; + backgroundColor: string | undefined; + } = { foregroundColor: undefined, backgroundColor: undefined } + ) => + makeServer( + jest.fn() as unknown as Sonos, + aService(), + url("http://localhost:1234"), + jest.fn() as unknown as MusicService, + new InMemoryLinkCodes(), + jest.fn() as unknown as AccessTokens, + clock, + iconColors + ); describe("invalid icon names", () => { [ @@ -1286,7 +1293,7 @@ describe("server", () => { ].forEach((type) => { describe(`trying to retrieve an icon with name ${type}`, () => { it(`should fail`, async () => { - const response = await request(server).get( + const response = await request(server()).get( `/icon/${type}/size/legacy` ); @@ -1300,7 +1307,7 @@ describe("server", () => { ["-1", "0", "59", "foo"].forEach((size) => { describe(`trying to retrieve an icon with size ${size}`, () => { it(`should fail`, async () => { - const response = await request(server).get( + const response = await request(server()).get( `/icon/artists/size/${size}` ); @@ -1311,11 +1318,22 @@ describe("server", () => { }); describe("fetching", () => { - Object.keys(ICONS).forEach((type) => { + [ + "artists", + "albums", + "playlists", + "genres", + "random", + "starred", + "recentlyAdded", + "recentlyPlayed", + "mostPlayed", + "discover", + ].forEach((type) => { describe(`type=${type}`, () => { describe(`legacy icon`, () => { it("should return the png image", async () => { - const response = await request(server).get( + const response = await request(server()).get( `/icon/${type}/size/legacy` ); @@ -1330,7 +1348,7 @@ describe("server", () => { describe("svg icon", () => { SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => { it(`should return an svg image for size = ${size}`, async () => { - const response = await request(server).get( + const response = await request(server()).get( `/icon/${type}/size/${size}` ); @@ -1339,12 +1357,33 @@ describe("server", () => { "image/svg+xml; charset=utf-8" ); const svg = Buffer.from(response.body).toString(); - expect(svg).toContain(`viewBox="0 0 ${size} ${size}"`); expect(svg).toContain( ` xmlns="http://www.w3.org/2000/svg" ` ); }); }); + + it("should return icon colors as per config if overriden", async () => { + const response = await request(server(SystemClock, { foregroundColor: 'brightblue', backgroundColor: 'brightpink' })).get( + `/icon/${type}/size/180` + ); + + expect(response.status).toEqual(200); + const svg = Buffer.from(response.body).toString(); + expect(svg).toContain(`fill:brightblue`); + expect(svg).toContain(`fill:brightpink`); + }); + + it("should return a christmas icon on christmas day", async () => { + const response = await request(server({ now: () => dayjs("2022/12/25") })).get( + `/icon/${type}/size/180` + ); + + expect(response.status).toEqual(200); + const svg = Buffer.from(response.body).toString(); + expect(svg).toContain(`fill:red`); + expect(svg).toContain(`fill:green`); + }); }); }); }); diff --git a/web/icons/Binoculars-14310.svg b/web/icons/Binoculars-14310.svg index ec17827..924c819 100644 --- a/web/icons/Binoculars-14310.svg +++ b/web/icons/Binoculars-14310.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/web/icons/Theatre-Mask-111172.svg b/web/icons/Theatre-Mask-111172.svg index 4543668..fcfada4 100644 --- a/web/icons/Theatre-Mask-111172.svg +++ b/web/icons/Theatre-Mask-111172.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/web/icons/index.html b/web/icons/index.html new file mode 100644 index 0000000..d1508dc --- /dev/null +++ b/web/icons/index.html @@ -0,0 +1,33 @@ + + + original
+
+ +
+ hack 1
+
+ +
+ hack 2
+
+ +
+ hack 3
+
+ +
+ + + \ No newline at end of file diff --git a/web/icons/navidrome-all.svg b/web/icons/navidrome-all.svg index a118836..4500052 100644 --- a/web/icons/navidrome-all.svg +++ b/web/icons/navidrome-all.svg @@ -1,3 +1,3 @@ -