Icons for genres with backgrounds, text, and ability to specify text color and font family (#34)

This commit is contained in:
Simon J
2021-08-27 18:14:09 +10:00
committed by GitHub
parent d1f00f549c
commit 29493e090a
38 changed files with 902 additions and 144 deletions

View File

@@ -16,7 +16,7 @@ export default function () {
process.exit(1);
}
const colorFrom = (envVar: string) => {
const wordFrom = (envVar: string) => {
const value = process.env[envVar];
if (value && value != "") {
if (value.match(/^\w+$/)) return value;
@@ -31,8 +31,10 @@ export default function () {
bonobUrl: url(bonobUrl),
secret: process.env["BONOB_SECRET"] || "bonob",
icons: {
foregroundColor: colorFrom("BONOB_ICON_FOREGROUND_COLOR"),
backgroundColor: colorFrom("BONOB_ICON_BACKGROUND_COLOR"),
foregroundColor: wordFrom("BONOB_ICON_FOREGROUND_COLOR"),
backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"),
fontColor: wordFrom("BONOB_ICON_FONT_COLOR"),
fontFamily: wordFrom("BONOB_ICON_FONT_FAMILY")
},
sonos: {
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",

View File

@@ -1,11 +1,24 @@
import libxmljs, { Element, Attribute } from "libxmljs2";
import _ from "underscore";
import { Clock, isChristmas, isCNY, isHalloween, isHoli, SystemClock } from "./clock";
import fs from "fs";
import {
Clock,
isChristmas,
isCNY,
isHalloween,
isHoli,
SystemClock,
} from "./clock";
import path from "path";
export type Transformation = {
viewPortIncreasePercent: number | undefined;
backgroundColor: string | undefined;
foregroundColor: string | undefined;
text: string | undefined;
fontColor: string | undefined;
fontFamily: string | undefined;
};
const SVG_NS = {
@@ -43,13 +56,23 @@ export interface Icon {
export class ColorOverridingIcon implements Icon {
rule: () => Boolean;
newColors: () => Pick<Transformation, "backgroundColor" | "foregroundColor">;
newColors: () => Partial<
Pick<
Transformation,
"backgroundColor" | "foregroundColor" | "fontColor" | "fontFamily"
>
>;
icon: Icon;
constructor(
icon: Icon,
rule: () => Boolean,
newColors: () => Pick<Transformation, "backgroundColor" | "foregroundColor">
newColors: () => Partial<
Pick<
Transformation,
"backgroundColor" | "foregroundColor" | "fontColor" | "fontFamily"
>
>
) {
this.icon = icon;
this.rule = rule;
@@ -74,6 +97,9 @@ export class SvgIcon implements Icon {
viewPortIncreasePercent: undefined,
backgroundColor: undefined,
foregroundColor: undefined,
text: undefined,
fontColor: undefined,
fontFamily: undefined,
}
) {
this.svg = svg;
@@ -106,26 +132,62 @@ export class SvgIcon implements Icon {
y: `${viewBox.minY}`,
width: `${Math.abs(viewBox.minX) + viewBox.width}`,
height: `${Math.abs(viewBox.minY) + viewBox.height}`,
style: `fill:${this.transformation.backgroundColor}`,
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}` })
(xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => {
if (path.attr("fill"))
path.attr({ stroke: this.transformation.foregroundColor! });
else path.attr({ fill: this.transformation.foregroundColor! });
});
}
if (this.transformation.text) {
const w = Math.abs(viewBox.minX) + Math.abs(viewBox.width);
const h = Math.abs(viewBox.minY) + Math.abs(viewBox.height);
const i = Math.floor(0.1 * w);
let attr: any = {
"font-size": `${Math.floor(h / 4)}`,
"font-weight": "bold",
};
if (this.transformation.fontFamily)
attr = { ...attr, "font-family": this.transformation.fontFamily };
if (this.transformation.fontColor)
attr = { ...attr, style: `fill:${this.transformation.fontColor}` };
const g = new Element(xml, "g");
g.attr(attr);
(xml.get("//svg:svg", SVG_NS) as Element).addChild(
g.addChild(
new Element(xml, "text")
.attr({
x: `${viewBox.minX + i}`,
y: `${viewBox.minY + Math.floor(0.8 * h)}`,
})
.text(this.transformation.text)
)
);
}
return xml.toString();
};
}
export const HOLI_COLORS = ["#06bceb", "#9fc717", "#fbdc10", "#f00b9a", "#fa9705"]
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">
colors: Pick<
Transformation,
"backgroundColor" | "foregroundColor" | "fontColor"
>
) =>
new ColorOverridingIcon(
icon,
@@ -133,22 +195,176 @@ export const makeFestive = (icon: Icon, clock: Clock = SystemClock): Icon => {
() => colors
);
const xmas = wrap(icon, isChristmas, {
let result = icon;
const apply = (
rule: (clock: Clock) => boolean,
colors: Pick<
Transformation,
"backgroundColor" | "foregroundColor" | "fontColor"
>
) => (result = wrap(result, rule, colors));
apply(isChristmas, {
backgroundColor: "green",
foregroundColor: "red",
fontColor: "white",
});
const randomHoliColors = _.shuffle([...HOLI_COLORS]);
const holi = wrap(xmas, isHoli, {
apply(isHoli, {
backgroundColor: randomHoliColors.pop(),
foregroundColor: randomHoliColors.pop(),
fontColor: randomHoliColors.pop(),
});
const cny = wrap(holi, isCNY, {
apply(isCNY, {
backgroundColor: "red",
foregroundColor: "yellow",
fontColor: "crimson",
});
const halloween = wrap(cny, isHalloween, {
apply(isHalloween, {
backgroundColor: "orange",
foregroundColor: "black",
fontColor: "orangered",
});
return halloween;
return result;
};
export type ICON =
| "artists"
| "albums"
| "playlists"
| "genres"
| "random"
| "starred"
| "recentlyAdded"
| "recentlyPlayed"
| "mostPlayed"
| "discover"
| "blank"
| "mushroom"
| "african"
| "rock"
| "metal"
| "punk"
| "americana"
| "guitar"
| "book"
| "oz"
| "rap"
| "horror"
| "hipHop"
| "pop"
| "blues"
| "classical"
| "comedy"
| "vinyl"
| "electronic"
| "pills"
| "trumpet"
| "conductor"
| "reggae"
| "music";
const iconFrom = (name: string) =>
new SvgIcon(
fs
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
.toString()
).with({ viewPortIncreasePercent: 50 });
export const ICONS: Record<ICON, Icon> = {
artists: iconFrom("navidrome-artists.svg"),
albums: iconFrom("navidrome-all.svg"),
blank: iconFrom("blank.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"),
mushroom: iconFrom("Mushroom-63864.svg"),
african: iconFrom("Africa-48087.svg"),
rock: iconFrom("Rock-Music-11007.svg"),
metal: iconFrom("Metal-Music-17763.svg"),
punk: iconFrom("Punk-40450.svg"),
americana: iconFrom("US-Capitol-104805.svg"),
guitar: iconFrom("Guitar-110433.svg"),
book: iconFrom("Book-453.svg"),
oz: iconFrom("Kangaroo-16730.svg"),
hipHop: iconFrom("Hip-Hop Music-17757.svg"),
rap: iconFrom("Rap-24851.svg"),
horror: iconFrom("Horror-4387.svg"),
pop: iconFrom("Ice-Pop Yellow-94532.svg"),
blues: iconFrom("Blues-113548.svg"),
classical: iconFrom("Classic-Music-11646.svg"),
comedy: iconFrom("Comedy-2-599.svg"),
vinyl: iconFrom("Music-Record-102104.svg"),
electronic: iconFrom("Electronic-Music-17745.svg"),
pills: iconFrom("Pills-112386.svg"),
trumpet: iconFrom("Trumpet-17823.svg"),
conductor: iconFrom("Music-Conductor-225.svg"),
reggae: iconFrom("Reggae-24843.svg"),
music: iconFrom("Music-14097.svg")
};
export type RULE = (genre: string) => boolean;
const eq =
(expected: string): RULE =>
(value: string) =>
expected.toLowerCase() === value.toLowerCase();
const contains =
(expected: string): RULE =>
(value: string) =>
value.toLowerCase().includes(expected.toLowerCase());
const containsWithAllTheNonWordCharsRemoved =
(expected: string): RULE =>
(value: string) =>
value.replace(/\W+/, " ").toLowerCase().includes(expected.toLowerCase());
const GENRE_RULES: [RULE, ICON][] = [
[eq("Acid House"), "mushroom"],
[contains("Goa"), "mushroom"],
[contains("Psy"), "mushroom"],
[eq("African"), "african"],
[eq("Americana"), "americana"],
[contains("Rock"), "rock"],
[contains("Folk"), "guitar"],
[contains("Book"), "book"],
[contains("Australian"), "oz"],
[contains("Rap"), "rap"],
[containsWithAllTheNonWordCharsRemoved("Hip Hop"), "hipHop"],
[contains("Horror"), "horror"],
[contains("Metal"), "metal"],
[contains("Punk"), "punk"],
[contains("Pop"), "pop"],
[contains("Blues"), "blues"],
[contains("Classical"), "classical"],
[contains("Comedy"), "comedy"],
[contains("Dub"), "vinyl"],
[contains("Turntable"), "vinyl"],
[contains("Electro"), "electronic"],
[contains("Trance"), "pills"],
[contains("Techno"), "pills"],
[contains("House"), "pills"],
[contains("Rave"), "pills"],
[contains("Jazz"), "trumpet"],
[contains("Orchestra"), "conductor"],
[contains("Reggae"), "reggae"],
];
export function iconForGenre(genre: string): ICON {
const [_, name] = GENRE_RULES.find(([rule, _]) => rule(genre)) || [
"music",
"music",
];
return name! as ICON;
}

View File

@@ -4,7 +4,6 @@ import * as Eta from "eta";
import morgan from "morgan";
import path from "path";
import sharp from "sharp";
import fs from "fs";
import { PassThrough, Transform, TransformCallback } from "stream";
@@ -17,7 +16,6 @@ import {
LOGIN_ROUTE,
CREATE_REGISTRATION_ROUTE,
REMOVE_REGISTRATION_ROUTE,
ICON,
} from "./smapi";
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service";
@@ -28,7 +26,7 @@ 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";
import { Icon, makeFestive, ICONS } from "./icon";
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
@@ -103,27 +101,6 @@ 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(
@@ -383,6 +360,9 @@ function server(
app.get("/icon/:type/size/:size", (req, res) => {
const type = req.params["type"]!;
const size = req.params["size"]!;
const text: string | undefined = req.query.text
? (req.query.text as string)
: undefined;
if (!Object.keys(ICONS).includes(type)) {
return res.status(404).send();
@@ -392,7 +372,7 @@ function server(
) {
return res.status(400).send();
} else {
const icon = (ICONS as any)[type]! as Icon;
let icon = (ICONS as any)[type]! as Icon;
const spec =
size == "legacy"
? {
@@ -406,7 +386,9 @@ function server(
Promise.resolve(svg),
};
return Promise.resolve(icon.toString())
return Promise.resolve(
makeFestive(icon.with({ text, ...iconColors }), clock).toString()
)
.then(spec.responseFormatter)
.then((data) => res.status(200).type(spec.mimeType).send(data));
}

View File

@@ -23,6 +23,7 @@ import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
import { Clock } from "./clock";
import { URLBuilder } from "./url_builder";
import { asLANGs, I8N } from "./i8n";
import { ICON, iconForGenre } from "./icon";
export const LOGIN_ROUTE = "/login";
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
@@ -46,8 +47,6 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [
"1500",
];
export type ICON = "artists" | "albums" | "playlists" | "genres" | "random" | "starred" | "recentlyAdded" | "recentlyPlayed" | "mostPlayed" | "discover"
const WSDL_FILE = path.resolve(
__dirname,
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
@@ -209,10 +208,11 @@ export type Container = {
displayType: string | undefined;
};
const genre = (genre: Genre) => ({
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
itemType: "container",
id: `genre:${genre.id}`,
title: genre.name,
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name), genre.name).href(),
});
const playlist = (playlist: PlaylistSummary) => ({
@@ -230,8 +230,8 @@ const playlist = (playlist: PlaylistSummary) => ({
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
bonobUrl.append({ pathname: `/icon/${icon}/size/legacy` });
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) =>
bonobUrl.append({ pathname: `/icon/${icon}/size/legacy`, searchParams: text ? { text } : {} });
export const defaultArtistArtURI = (
bonobUrl: URLBuilder,
@@ -688,7 +688,7 @@ function bindSmapiSoapServiceToExpress(
.then(slice2(paging))
.then(([page, total]) =>
getMetadataResult({
mediaCollection: page.map(genre),
mediaCollection: page.map(it => genre(bonobUrl, it)),
index: paging._index,
total,
})