mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Icons for genres with backgrounds, text, and ability to specify text color and font family (#34)
This commit is contained in:
@@ -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",
|
||||
|
||||
242
src/icon.ts
242
src/icon.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
12
src/smapi.ts
12
src/smapi.ts
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user