Compare commits
44 Commits
feature/ar
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4fcdff24 | ||
|
|
91cc450451 | ||
|
|
c1815e5e48 | ||
|
|
287e203449 | ||
|
|
92be208a35 | ||
|
|
9d728040e1 | ||
|
|
8600b9ec85 | ||
|
|
588141e569 | ||
|
|
b99ff0e5dc | ||
|
|
9092050c37 | ||
|
|
f8f8224213 | ||
|
|
9dcac1f324 | ||
|
|
f045867554 | ||
|
|
0f8c45cd03 | ||
|
|
00f6a9ff8f | ||
|
|
c73f79532d | ||
|
|
727affe572 | ||
|
|
59bb702679 | ||
|
|
ee0a0747ee | ||
|
|
ca9bf2fc04 | ||
|
|
89accef1e6 | ||
|
|
ae29bc14eb | ||
|
|
e2e73209a2 | ||
|
|
543d352204 | ||
|
|
678c8390cc | ||
|
|
29493e090a | ||
|
|
d1f00f549c | ||
|
|
b900863c78 | ||
|
|
3bb6776880 | ||
|
|
81d7ea3fe9 | ||
|
|
3970ab5cd3 | ||
|
|
75d8c576c3 | ||
|
|
77fab65d82 | ||
|
|
8dc98ee1a5 | ||
|
|
06db0c2088 | ||
|
|
c8509a23d4 | ||
|
|
2a54eadb3e | ||
|
|
0ad1cd5c40 | ||
|
|
3545d9c653 | ||
|
|
432248fb47 | ||
|
|
2cfd52415c | ||
|
|
c67f74bf08 | ||
|
|
db0351da39 | ||
|
|
5674cd1aa6 |
61
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.6.x
|
||||
-
|
||||
run: yarn install
|
||||
-
|
||||
run: yarn test
|
||||
|
||||
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
needs: build_and_test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: simojenki/bonob
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
34
.github/workflows/master.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
# pull_request:
|
||||
# branches: [ master ]
|
||||
|
||||
jobs:
|
||||
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
- run: yarn install
|
||||
- run: yarn test
|
||||
|
||||
push_to_registry:
|
||||
needs: build_and_test
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: simojenki/bonob
|
||||
tag_with_ref: true
|
||||
15
.github/workflows/pr.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Test PR
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.x
|
||||
- run: yarn install
|
||||
- run: yarn test
|
||||
16
Dockerfile
@@ -2,7 +2,10 @@ FROM node:16.6-alpine as build
|
||||
|
||||
WORKDIR /bonob
|
||||
|
||||
COPY .git ./.git
|
||||
COPY src ./src
|
||||
COPY docs ./docs
|
||||
COPY typings ./typings
|
||||
COPY web ./web
|
||||
COPY tests ./tests
|
||||
COPY jest.config.js .
|
||||
@@ -17,8 +20,10 @@ RUN apk add --no-cache --update --virtual .gyp \
|
||||
vips-dev \
|
||||
python3 \
|
||||
make \
|
||||
git \
|
||||
g++ && \
|
||||
yarn install --immutable && \
|
||||
yarn gitinfo && \
|
||||
yarn test --no-cache && \
|
||||
yarn build
|
||||
|
||||
@@ -34,13 +39,16 @@ WORKDIR /bonob
|
||||
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
COPY --from=build /bonob/build/src/* ./
|
||||
|
||||
COPY --from=build /bonob/build/src ./src
|
||||
COPY --from=build /bonob/node_modules ./node_modules
|
||||
COPY web web
|
||||
COPY src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl /bonob/Sonoswsdl-1.19.4-20190411.142401-3.wsdl
|
||||
COPY --from=build /bonob/.gitinfo ./
|
||||
COPY web ./web
|
||||
COPY src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl ./src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl
|
||||
|
||||
RUN apk add --no-cache --update vips
|
||||
|
||||
USER nobody
|
||||
WORKDIR /bonob/src
|
||||
|
||||
CMD ["node", "./app.js"]
|
||||
CMD ["node", "app.js"]
|
||||
52
README.md
@@ -23,29 +23,33 @@ Currently only a single integration allowing Navidrome to be registered with son
|
||||
- Ability to play a playlist
|
||||
- Ability to add/remove playlists
|
||||
- Ability to add/remove tracks from a playlist
|
||||
- Localization (only en-US & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://developer.sonos.com/build/content-service-add-features/strings-and-localization/)
|
||||
|
||||
## Running
|
||||
|
||||
bonob is ditributed via docker and can be run in a number of ways
|
||||
|
||||
### Full sonos device auto-discovery by using docker --network host
|
||||
### Full sonos device auto-discovery and auto-registration using docker --network host
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=true \
|
||||
-e BONOB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-p 4534:4534 \
|
||||
--network host \
|
||||
simojenki/bonob
|
||||
```
|
||||
|
||||
Now open http://localhost:4534 in your browser, you should see sonos devices, and service configuration. By pressing 'Re-register' bonob will register itself in your sonos system, and should then show up in the "Services" list.
|
||||
Now open http://localhost:4534 in your browser, you should see sonos devices, and service configuration. Bonob will auto-register itself with your sonos system on startup.
|
||||
|
||||
### Full sonos device auto-discovery and auto-registration on custom port by using a sonos seed device, without requiring docker host networking
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e BONOB_PORT=3000 \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=true \
|
||||
-e BONOB_SONOS_SEED_HOST=192.168.1.123 \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=true \
|
||||
-e BONOB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-p 3000:3000 \
|
||||
simojenki/bonob
|
||||
```
|
||||
@@ -75,6 +79,8 @@ docker run \
|
||||
|
||||
Now within the LAN that contains the sonos devices run bonob the registration process.
|
||||
|
||||
#### Using auto-discovery
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--rm \
|
||||
@@ -82,6 +88,16 @@ docker run \
|
||||
simojenki/bonob register https://my-server.example.com/bonob
|
||||
```
|
||||
|
||||
#### Using a seed host
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--rm \
|
||||
-e BONOB_SONOS_SEED_HOST=192.168.1.163 \
|
||||
simojenki/bonob register https://my-server.example.com/bonob
|
||||
```
|
||||
|
||||
|
||||
### Running bonob and navidrome using docker-compose
|
||||
|
||||
```yaml
|
||||
@@ -113,9 +129,9 @@ services:
|
||||
# ip address of your machine running bonob
|
||||
BONOB_URL: http://192.168.1.111:4534
|
||||
BONOB_SECRET: changeme
|
||||
BONOB_SONOS_SERVICE_ID: 246
|
||||
BONOB_SONOS_AUTO_REGISTER: "true"
|
||||
BONOB_SONOS_DEVICE_DISCOVERY: "true"
|
||||
BONOB_SONOS_AUTO_REGISTER: true
|
||||
BONOB_SONOS_DEVICE_DISCOVERY: true
|
||||
BONOB_SONOS_SERVICE_ID: 246
|
||||
# ip address of one of your sonos devices
|
||||
BONOB_SONOS_SEED_HOST: 192.168.1.121
|
||||
BONOB_NAVIDROME_URL: http://navidrome:4533
|
||||
@@ -137,9 +153,13 @@ 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 [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
|
||||
## Initialising service within sonos app
|
||||
|
||||
- Configure bonob, make sure to set BONOB_URL. **bonob must be accessible from your sonos devices on BONOB_URL, otherwise it will fail to initialise within the sonos app, so make sure you test this in your browser by putting BONOB_URL in the address bar and seeing the bonob information page**
|
||||
- Start bonob,
|
||||
- Open sonos app on your device
|
||||
- Settings -> Services & Voice -> + Add a Service
|
||||
- Select your Music Service, default name is 'bonob', can be overriden with configuration BONOB_SONOS_SERVICE_NAME
|
||||
@@ -155,6 +175,26 @@ BONOB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||
- Implement the MusicService/MusicLibrary interface
|
||||
- Startup bonob with your new implementation.
|
||||
|
||||
## Sample Icon colors
|
||||
|
||||
```
|
||||
-e BONOB_ICON_FOREGROUND_COLOR=white \
|
||||
-e BONOB_ICON_BACKGROUND_COLOR=darkgrey
|
||||
```
|
||||

|
||||
|
||||
|
||||
```
|
||||
-e BONOB_ICON_FOREGROUND_COLOR=chartreuse \
|
||||
-e BONOB_ICON_BACKGROUND_COLOR=fuchsia
|
||||
```
|
||||

|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho
|
||||
|
||||
## TODO
|
||||
|
||||
- Artist Radio
|
||||
|
||||
BIN
docs/images/chartreuseFuchsia.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/images/whiteDarkGrey.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -2,4 +2,8 @@ module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
|
||||
modulePathIgnorePatterns: [
|
||||
'<rootDir>/node_modules',
|
||||
'<rootDir>/build',
|
||||
],
|
||||
};
|
||||
66
package.json
@@ -6,50 +6,54 @@
|
||||
"author": "simojenki <simojenki@users.noreply.github.com>",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@svrooij/sonos": "^2.3.0",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/morgan": "^1.9.2",
|
||||
"@types/node": "^14.14.22",
|
||||
"@types/sharp": "^0.27.1",
|
||||
"@types/underscore": "1.10.24",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"axios": "^0.21.1",
|
||||
"dayjs": "^1.10.4",
|
||||
"eta": "^1.12.1",
|
||||
"@svrooij/sonos": "^2.4.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/node": "^16.7.13",
|
||||
"@types/sharp": "^0.28.6",
|
||||
"@types/underscore": "^1.11.3",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"axios": "^0.21.4",
|
||||
"dayjs": "^1.10.6",
|
||||
"eta": "^1.12.3",
|
||||
"express": "^4.17.1",
|
||||
"fp-ts": "^2.9.5",
|
||||
"fp-ts": "^2.11.1",
|
||||
"libxmljs2": "^0.28.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-html-parser": "^2.1.0",
|
||||
"sharp": "^0.27.2",
|
||||
"soap": "^0.37.0",
|
||||
"ts-md5": "^1.2.7",
|
||||
"typescript": "^4.1.3",
|
||||
"underscore": "^1.12.1",
|
||||
"node-html-parser": "^4.1.4",
|
||||
"sharp": "^0.29.1",
|
||||
"soap": "^0.42.0",
|
||||
"ts-md5": "^1.2.9",
|
||||
"typescript": "^4.4.2",
|
||||
"underscore": "^1.13.1",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"x2js": "^3.4.1"
|
||||
"x2js": "^3.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.14",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/mocha": "^8.2.0",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"chai": "^4.2.0",
|
||||
"@types/chai": "^4.2.21",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"chai": "^4.3.4",
|
||||
"get-port": "^5.1.1",
|
||||
"jest": "^26.6.3",
|
||||
"nodemon": "^2.0.7",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^26.4.4",
|
||||
"image-js": "^0.33.0",
|
||||
"jest": "^27.1.0",
|
||||
"nodemon": "^2.0.12",
|
||||
"supertest": "^6.1.6",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"ts-node": "^10.2.1",
|
||||
"xmldom-ts": "^0.3.1",
|
||||
"xpath-ts": "^1.3.13"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -Rf build",
|
||||
"clean": "rm -Rf build node_modules",
|
||||
"build": "tsc",
|
||||
"dev": "BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon ./src/app.ts",
|
||||
"dev": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
||||
"devr": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
||||
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
|
||||
"test": "jest --testPathIgnorePatterns=build"
|
||||
"test": "jest",
|
||||
"gitinfo": "git describe --tags > .gitinfo"
|
||||
}
|
||||
}
|
||||
|
||||
23
src/app.ts
@@ -1,3 +1,5 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
import { appendMimeTypeToClientFor, DEFAULT, Navidrome } from "./navidrome";
|
||||
@@ -20,7 +22,7 @@ const bonob = bonobService(
|
||||
"AppLink"
|
||||
);
|
||||
|
||||
const sonosSystem = sonos(config.sonos.deviceDiscovery, config.sonos.seedHost);
|
||||
const sonosSystem = sonos(config.sonos.discovery);
|
||||
|
||||
const streamUserAgent = config.navidrome.customClientsFor
|
||||
? appendMimeTypeToClientFor(config.navidrome.customClientsFor.split(","))
|
||||
@@ -56,15 +58,24 @@ const featureFlagAwareMusicService: MusicService = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const GIT_INFO = path.join(__dirname, "..", ".gitinfo");
|
||||
|
||||
const version = fs.existsSync(GIT_INFO) ? fs.readFileSync(GIT_INFO).toString().trim() : "v??"
|
||||
|
||||
const app = server(
|
||||
sonosSystem,
|
||||
bonob,
|
||||
config.bonobUrl,
|
||||
featureFlagAwareMusicService,
|
||||
new InMemoryLinkCodes(),
|
||||
new InMemoryAccessTokens(sha256(config.secret)),
|
||||
SystemClock,
|
||||
true,
|
||||
{
|
||||
linkCodes: () => new InMemoryLinkCodes(),
|
||||
accessTokens: () => new InMemoryAccessTokens(sha256(config.secret)),
|
||||
clock: SystemClock,
|
||||
iconColors: config.icons,
|
||||
applyContextPath: true,
|
||||
logRequests: true,
|
||||
version
|
||||
}
|
||||
);
|
||||
|
||||
app.listen(config.port, () => {
|
||||
@@ -79,7 +90,7 @@ if (config.sonos.autoRegister) {
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if(config.sonos.deviceDiscovery) {
|
||||
} else if(config.sonos.discovery.auto) {
|
||||
sonosSystem.devices().then(devices => {
|
||||
devices.forEach(d => {
|
||||
logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
|
||||
export const isChristmas = (clock: Clock = SystemClock) => clock.now().month() == 11 && clock.now().date() == 25;
|
||||
export const isMay4 = (clock: Clock = SystemClock) => clock.now().month() == 4 && clock.now().date() == 4;
|
||||
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 const isCNY_2022 = (clock: Clock = SystemClock) => clock.now().isSame(dayjs("2022/02/01"))
|
||||
export const isCNY_2023 = (clock: Clock = SystemClock) => clock.now().isSame(dayjs("2023/01/22"))
|
||||
export const isCNY_2024 = (clock: Clock = SystemClock) => clock.now().isSame(dayjs("2024/02/10"))
|
||||
|
||||
export interface Clock {
|
||||
now(): Dayjs;
|
||||
}
|
||||
|
||||
@@ -16,15 +16,31 @@ export default function () {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const wordFrom = (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: wordFrom("BONOB_ICON_FOREGROUND_COLOR"),
|
||||
backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"),
|
||||
},
|
||||
sonos: {
|
||||
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
|
||||
deviceDiscovery:
|
||||
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true",
|
||||
seedHost: process.env["BONOB_SONOS_SEED_HOST"],
|
||||
discovery: {
|
||||
auto:
|
||||
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true",
|
||||
seedHost: process.env["BONOB_SONOS_SEED_HOST"],
|
||||
},
|
||||
autoRegister:
|
||||
(process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true",
|
||||
sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"),
|
||||
|
||||
28
src/i8n.ts
@@ -3,7 +3,8 @@ import { pipe } from "fp-ts/lib/function";
|
||||
import { option as O } from "fp-ts";
|
||||
import _ from "underscore";
|
||||
|
||||
export type LANG = "en-US" | "nl-NL";
|
||||
export type LANG = "en-US" | "da-DK" | "de-DE" | "es-ES" | "fr-FR" | "it-IT" | "ja-JP" | "nb-NO" | "nl-NL" | "pt-BR" | "sv-SE" | "zh-CN"
|
||||
export type SUPPORTED_LANG = "en-US" | "nl-NL";
|
||||
export type KEY =
|
||||
| "AppLinkMessage"
|
||||
| "artists"
|
||||
@@ -35,9 +36,10 @@ export type KEY =
|
||||
| "failedToRemoveRegistration"
|
||||
| "invalidLinkCode"
|
||||
| "loginSuccessful"
|
||||
| "loginFailed";
|
||||
| "loginFailed"
|
||||
| "noSonosDevices";
|
||||
|
||||
const translations: Record<LANG, Record<KEY, string>> = {
|
||||
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
"en-US": {
|
||||
AppLinkMessage: "Linking sonos with $BONOB_SONOS_SERVICE_NAME",
|
||||
artists: "Artists",
|
||||
@@ -70,6 +72,7 @@ const translations: Record<LANG, Record<KEY, string>> = {
|
||||
invalidLinkCode: "Invalid linkCode!",
|
||||
loginSuccessful: "Login successful!",
|
||||
loginFailed: "Login failed!",
|
||||
noSonosDevices: "No sonos devices",
|
||||
},
|
||||
"nl-NL": {
|
||||
AppLinkMessage: "Sonos koppelen aan $BONOB_SONOS_SERVICE_NAME",
|
||||
@@ -88,7 +91,7 @@ const translations: Record<LANG, Record<KEY, string>> = {
|
||||
expectedConfig: "Verwachte configuratie",
|
||||
existingServiceConfig: "Bestaande serviceconfiguratie",
|
||||
noExistingServiceRegistration: "Geen bestaande serviceregistratie",
|
||||
register: "Register",
|
||||
register: "Registreren",
|
||||
removeRegistration: "Verwijder registratie",
|
||||
devices: "Apparaten",
|
||||
services: "Services",
|
||||
@@ -103,12 +106,20 @@ const translations: Record<LANG, Record<KEY, string>> = {
|
||||
invalidLinkCode: "Ongeldige linkcode!",
|
||||
loginSuccessful: "Inloggen gelukt!",
|
||||
loginFailed: "Inloggen mislukt!",
|
||||
noSonosDevices: "Geen Sonos-apparaten",
|
||||
},
|
||||
};
|
||||
|
||||
const translationsLookup = Object.keys(translations).reduce((lookups, lang) => {
|
||||
lookups.set(lang, translations[lang as SUPPORTED_LANG]);
|
||||
lookups.set(lang.toLocaleLowerCase(), translations[lang as SUPPORTED_LANG]);
|
||||
lookups.set(lang.toLocaleLowerCase().split("-")[0]!, translations[lang as SUPPORTED_LANG]);
|
||||
return lookups;
|
||||
}, new Map<string, Record<KEY, string>>())
|
||||
|
||||
export const randomLang = () => _.shuffle(["en-US", "nl-NL"])[0]!;
|
||||
|
||||
export const asLANGs = (acceptLanguageHeader: string | undefined) =>
|
||||
export const asLANGs = (acceptLanguageHeader: string | undefined): LANG[] =>
|
||||
pipe(
|
||||
acceptLanguageHeader,
|
||||
O.fromNullable,
|
||||
@@ -118,7 +129,8 @@ export const asLANGs = (acceptLanguageHeader: string | undefined) =>
|
||||
pipe(
|
||||
it.split(","),
|
||||
A.map((it) => it.trim()),
|
||||
A.filter((it) => it != "")
|
||||
A.filter((it) => it != ""),
|
||||
A.map(it => it as LANG)
|
||||
)
|
||||
),
|
||||
O.getOrElseW(() => [])
|
||||
@@ -130,12 +142,12 @@ export type Lang = (key: KEY) => string;
|
||||
|
||||
export const langs = () => Object.keys(translations);
|
||||
|
||||
export const keys = (lang: LANG = "en-US") => Object.keys(translations[lang]);
|
||||
export const keys = (lang: SUPPORTED_LANG = "en-US") => Object.keys(translations[lang]);
|
||||
|
||||
export default (serviceName: string): I8N =>
|
||||
(...langs: string[]): Lang => {
|
||||
const langToUse =
|
||||
langs.map((l) => translations[l as LANG]).find((it) => it) ||
|
||||
langs.map((l) => translationsLookup.get(l as SUPPORTED_LANG)).find((it) => it) ||
|
||||
translations["en-US"];
|
||||
return (key: KEY) => {
|
||||
const value = langToUse[key]?.replace(
|
||||
|
||||
470
src/icon.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import libxmljs, { Element, Attribute } from "libxmljs2";
|
||||
import _ from "underscore";
|
||||
import fs from "fs";
|
||||
|
||||
import {
|
||||
Clock,
|
||||
isChristmas,
|
||||
isCNY_2022,
|
||||
isCNY_2023,
|
||||
isCNY_2024,
|
||||
isHalloween,
|
||||
isHoli,
|
||||
isMay4,
|
||||
SystemClock,
|
||||
} from "./clock";
|
||||
import path from "path";
|
||||
|
||||
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 type IconFeatures = {
|
||||
viewPortIncreasePercent: number | undefined;
|
||||
backgroundColor: string | undefined;
|
||||
foregroundColor: string | undefined;
|
||||
};
|
||||
|
||||
export type IconSpec = {
|
||||
svg: string | undefined;
|
||||
features: Partial<IconFeatures> | undefined;
|
||||
};
|
||||
|
||||
export interface Icon {
|
||||
with(spec: Partial<IconSpec>): Icon;
|
||||
apply(transformer: Transformer): Icon;
|
||||
}
|
||||
|
||||
export type Transformer = (icon: Icon) => Icon;
|
||||
|
||||
export function transform(spec: Partial<IconSpec>): Transformer {
|
||||
return (icon: Icon) =>
|
||||
icon.with({
|
||||
...spec,
|
||||
features: { ...spec.features },
|
||||
});
|
||||
}
|
||||
|
||||
export function features(features: Partial<IconFeatures>): Transformer {
|
||||
return (icon: Icon) => icon.with({ features });
|
||||
}
|
||||
|
||||
export function maybeTransform(rule: () => Boolean, transformer: Transformer) {
|
||||
return (icon: Icon) => (rule() ? transformer(icon) : icon);
|
||||
}
|
||||
|
||||
export function allOf(...transformers: Transformer[]): Transformer {
|
||||
return (icon: Icon): Icon =>
|
||||
_.inject(
|
||||
transformers,
|
||||
(current: Icon, transformer: Transformer) => transformer(current),
|
||||
icon
|
||||
);
|
||||
}
|
||||
|
||||
export class SvgIcon implements Icon {
|
||||
svg: string;
|
||||
features: IconFeatures;
|
||||
|
||||
constructor(
|
||||
svg: string,
|
||||
features: Partial<IconFeatures> = {
|
||||
viewPortIncreasePercent: undefined,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
}
|
||||
) {
|
||||
this.svg = svg;
|
||||
this.features = {
|
||||
viewPortIncreasePercent: undefined,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
...features,
|
||||
};
|
||||
}
|
||||
|
||||
public apply = (transformer: Transformer): Icon => transformer(this);
|
||||
|
||||
public with = (spec: Partial<IconSpec>) =>
|
||||
new SvgIcon(spec.svg || this.svg, {
|
||||
...this.features,
|
||||
...spec.features,
|
||||
});
|
||||
|
||||
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.features.viewPortIncreasePercent &&
|
||||
this.features.viewPortIncreasePercent > 0
|
||||
) {
|
||||
viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent);
|
||||
viewBoxAttr.value(viewBox.toString());
|
||||
}
|
||||
if (this.features.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}`,
|
||||
fill: this.features.backgroundColor,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (this.features.foregroundColor) {
|
||||
(xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => {
|
||||
if (path.attr("fill"))
|
||||
path.attr({ stroke: this.features.foregroundColor! });
|
||||
else path.attr({ fill: this.features.foregroundColor! });
|
||||
});
|
||||
}
|
||||
return xml.toString();
|
||||
};
|
||||
}
|
||||
|
||||
export const HOLI_COLORS = [
|
||||
"#06bceb",
|
||||
"#9fc717",
|
||||
"#fbdc10",
|
||||
"#f00b9a",
|
||||
"#fa9705",
|
||||
];
|
||||
|
||||
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"
|
||||
| "error"
|
||||
| "chill"
|
||||
| "country"
|
||||
| "dance"
|
||||
| "disco"
|
||||
| "film"
|
||||
| "new"
|
||||
| "old"
|
||||
| "cannabis"
|
||||
| "trip"
|
||||
| "opera"
|
||||
| "world"
|
||||
| "violin"
|
||||
| "celtic"
|
||||
| "children"
|
||||
| "chillout"
|
||||
| "progressiveRock"
|
||||
| "christmas"
|
||||
| "halloween"
|
||||
| "yoDragon"
|
||||
| "yoRabbit"
|
||||
| "yoTiger"
|
||||
| "chapel"
|
||||
| "audioWave"
|
||||
| "c3po"
|
||||
| "chewy"
|
||||
| "darth"
|
||||
| "skywalker"
|
||||
| "leia"
|
||||
| "r2d2"
|
||||
| "yoda";
|
||||
|
||||
const iconFrom = (name: string) =>
|
||||
new SvgIcon(
|
||||
fs
|
||||
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
|
||||
.toString()
|
||||
);
|
||||
|
||||
export const ICONS: Record<ICON, SvgIcon> = {
|
||||
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("Opera-Glasses-102740.svg"),
|
||||
mushroom: iconFrom("Mushroom-63864.svg"),
|
||||
african: iconFrom("Africa-48087.svg"),
|
||||
rock: iconFrom("Rock-Music-11076.svg"),
|
||||
progressiveRock: iconFrom("Progressive-Rock-24862.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-22940.svg"),
|
||||
oz: iconFrom("Kangaroo-16730.svg"),
|
||||
hipHop: iconFrom("Hip-Hop Music-17757.svg"),
|
||||
rap: iconFrom("Rap-24851.svg"),
|
||||
horror: iconFrom("Horror-88855.svg"),
|
||||
pop: iconFrom("Ice-Pop Yellow-94532.svg"),
|
||||
blues: iconFrom("Blues-113548.svg"),
|
||||
classical: iconFrom("Classic-Music-17728.svg"),
|
||||
comedy: iconFrom("Comedy-5937.svg"),
|
||||
vinyl: iconFrom("Music-Record-102104.svg"),
|
||||
electronic: iconFrom("Electronic-Music-17745.svg"),
|
||||
pills: iconFrom("Pills-92954.svg"),
|
||||
trumpet: iconFrom("Trumpet-17823.svg"),
|
||||
conductor: iconFrom("Music-Conductor-225.svg"),
|
||||
reggae: iconFrom("Reggae-24843.svg"),
|
||||
music: iconFrom("Music-14097.svg"),
|
||||
error: iconFrom("Error-82783.svg"),
|
||||
chill: iconFrom("Fridge-282.svg"),
|
||||
country: iconFrom("Country-Music-113286.svg"),
|
||||
dance: iconFrom("Tango-25015.svg"),
|
||||
disco: iconFrom("Disco-Ball-25777.svg"),
|
||||
film: iconFrom("Film-Reel-3230.svg"),
|
||||
new: iconFrom("New-47652.svg"),
|
||||
old: iconFrom("Old-Woman-77881.svg"),
|
||||
cannabis: iconFrom("Cannabis-33270.svg"),
|
||||
trip: iconFrom("TripAdvisor-44407.svg"),
|
||||
opera: iconFrom("Sydney-Opera House-59090.svg"),
|
||||
world: iconFrom("Globe-1301.svg"),
|
||||
violin: iconFrom("Violin-3421.svg"),
|
||||
celtic: iconFrom("Scottish-Thistle-108212.svg"),
|
||||
children: iconFrom("Children-78186.svg"),
|
||||
chillout: iconFrom("Sleeping-in Bed-14385.svg"),
|
||||
christmas: iconFrom("Christmas-Tree-63332.svg"),
|
||||
halloween: iconFrom("Jack-o' Lantern-66580.svg"),
|
||||
yoDragon: iconFrom("Year-of Dragon-4537.svg"),
|
||||
yoRabbit: iconFrom("Year-of Rabbit-6313.svg"),
|
||||
yoTiger: iconFrom("Year-of Tiger-22776.svg"),
|
||||
chapel: iconFrom("Chapel-69791.svg"),
|
||||
audioWave: iconFrom("Audio-Wave-1892.svg"),
|
||||
c3po: iconFrom("C-3PO-31823.svg"),
|
||||
chewy: iconFrom("Chewbacca-89771.svg"),
|
||||
darth: iconFrom("Darth-Vader-35734.svg"),
|
||||
skywalker: iconFrom("Luke-Skywalker-39424.svg"),
|
||||
leia: iconFrom("Princess-Leia-68568.svg"),
|
||||
r2d2: iconFrom("R2-D2-39423.svg"),
|
||||
yoda: iconFrom("Yoda-68107.svg"),
|
||||
};
|
||||
|
||||
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];
|
||||
|
||||
export type RULE = (genre: string) => boolean;
|
||||
|
||||
export const eq =
|
||||
(expected: string): RULE =>
|
||||
(value: string) =>
|
||||
expected.toLowerCase() === value.toLowerCase();
|
||||
|
||||
export const contains =
|
||||
(expected: string): RULE =>
|
||||
(value: string) =>
|
||||
value.toLowerCase().includes(expected.toLowerCase());
|
||||
|
||||
export const containsWord =
|
||||
(expected: string): RULE =>
|
||||
(value: string) =>
|
||||
value.toLowerCase().split(/\W/).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"],
|
||||
[eq("African"), "african"],
|
||||
[eq("Americana"), "americana"],
|
||||
[eq("Film Score"), "film"],
|
||||
[eq("Soundtrack"), "film"],
|
||||
[eq("Stoner Rock"), "cannabis"],
|
||||
[eq("Turntablism"), "vinyl"],
|
||||
[eq("Celtic"), "celtic"],
|
||||
[eq("Progressive Rock"), "progressiveRock"],
|
||||
[containsWord("Christmas"), "christmas"],
|
||||
[containsWord("Kerst"), "christmas"], // christmas in dutch
|
||||
[containsWord("Country"), "country"],
|
||||
[containsWord("Rock"), "rock"],
|
||||
[containsWord("Folk"), "guitar"],
|
||||
[containsWord("Book"), "book"],
|
||||
[containsWord("Australian"), "oz"],
|
||||
[containsWord("Baroque"), "violin"],
|
||||
[containsWord("Rap"), "rap"],
|
||||
[containsWithAllTheNonWordCharsRemoved("Hip Hop"), "hipHop"],
|
||||
[containsWithAllTheNonWordCharsRemoved("Trip Hop"), "trip"],
|
||||
[containsWord("Metal"), "metal"],
|
||||
[containsWord("Punk"), "punk"],
|
||||
[containsWord("Blues"), "blues"],
|
||||
[eq("Classic"), "classical"],
|
||||
[containsWord("Classical"), "classical"],
|
||||
[containsWord("Comedy"), "comedy"],
|
||||
[containsWord("Komedie"), "comedy"], // dutch for Comedy
|
||||
[containsWord("Turntable"), "vinyl"],
|
||||
[containsWord("Dub"), "electronic"],
|
||||
[eq("Dubstep"), "electronic"],
|
||||
[eq("Drum And Bass"), "electronic"],
|
||||
[contains("Goa"), "mushroom"],
|
||||
[contains("Psy"), "mushroom"],
|
||||
[containsWord("Trance"), "pills"],
|
||||
[containsWord("Techno"), "pills"],
|
||||
[containsWord("House"), "pills"],
|
||||
[containsWord("Rave"), "pills"],
|
||||
[containsWord("Jazz"), "trumpet"],
|
||||
[containsWord("Orchestra"), "conductor"],
|
||||
[containsWord("Reggae"), "reggae"],
|
||||
[containsWord("Disco"), "disco"],
|
||||
[containsWord("New"), "new"],
|
||||
[containsWord("Opera"), "opera"],
|
||||
[containsWord("Vocal"), "opera"],
|
||||
[containsWord("Ballad"), "opera"],
|
||||
[containsWord("Western"), "country"],
|
||||
[containsWord("World"), "world"],
|
||||
[contains("Electro"), "electronic"],
|
||||
[contains("Dance"), "dance"],
|
||||
[contains("Pop"), "pop"],
|
||||
[contains("Horror"), "horror"],
|
||||
[contains("Children"), "children"],
|
||||
[contains("Chill"), "chill"],
|
||||
[contains("Old"), "old"],
|
||||
[containsWord("Christian"), "chapel"],
|
||||
[containsWord("Religious"), "chapel"],
|
||||
[containsWord("Spoken"), "audioWave"],
|
||||
];
|
||||
|
||||
export function iconForGenre(genre: string): ICON {
|
||||
const [_, name] = GENRE_RULES.find(([rule, _]) => rule(genre)) || [
|
||||
"music",
|
||||
"music",
|
||||
];
|
||||
return name! as ICON;
|
||||
}
|
||||
|
||||
export const festivals = (clock: Clock = SystemClock): Transformer => {
|
||||
const randomHoliColors = _.shuffle([...HOLI_COLORS]);
|
||||
return allOf(
|
||||
maybeTransform(
|
||||
() => isChristmas(clock),
|
||||
transform({
|
||||
svg: ICONS.christmas.svg,
|
||||
features: {
|
||||
backgroundColor: "green",
|
||||
foregroundColor: "red",
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isHoli(clock),
|
||||
transform({
|
||||
features: {
|
||||
backgroundColor: randomHoliColors.pop(),
|
||||
foregroundColor: randomHoliColors.pop(),
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isCNY_2022(clock),
|
||||
transform({
|
||||
svg: ICONS.yoTiger.svg,
|
||||
features: {
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isCNY_2023(clock),
|
||||
transform({
|
||||
svg: ICONS.yoRabbit.svg,
|
||||
features: {
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isCNY_2024(clock),
|
||||
transform({
|
||||
svg: ICONS.yoDragon.svg,
|
||||
features: {
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isHalloween(clock),
|
||||
transform({
|
||||
svg: ICONS.halloween.svg,
|
||||
features: {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "orange",
|
||||
},
|
||||
})
|
||||
),
|
||||
maybeTransform(
|
||||
() => isMay4(clock),
|
||||
transform({
|
||||
svg: STAR_WARS[_.random(STAR_WARS.length - 1)]!.svg,
|
||||
features: {
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -99,7 +99,7 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
||||
|
||||
export type ArtistQuery = Paging;
|
||||
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'byGenre' | 'random' | 'recent' | 'frequent' | 'newest' | 'starred';
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recent' | 'frequent' | 'newest' | 'starred';
|
||||
|
||||
export type AlbumQuery = Paging & {
|
||||
type: AlbumQueryType;
|
||||
@@ -120,6 +120,11 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
artistId: it.artistId,
|
||||
});
|
||||
|
||||
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
|
||||
id: it.id,
|
||||
name: it.name
|
||||
})
|
||||
|
||||
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
|
||||
|
||||
export type TrackStream = {
|
||||
|
||||
134
src/navidrome.ts
@@ -90,7 +90,7 @@ export type GetArtistsResponse = SubsonicResponse & {
|
||||
};
|
||||
|
||||
export type GetAlbumListResponse = SubsonicResponse & {
|
||||
albumList: {
|
||||
albumList2: {
|
||||
album: album[];
|
||||
};
|
||||
};
|
||||
@@ -101,7 +101,7 @@ export type genre = {
|
||||
__text: string;
|
||||
};
|
||||
|
||||
export type GenGenresResponse = SubsonicResponse & {
|
||||
export type GetGenresResponse = SubsonicResponse & {
|
||||
genres: {
|
||||
genre: genre[];
|
||||
};
|
||||
@@ -130,7 +130,7 @@ export type ArtistInfo = {
|
||||
};
|
||||
|
||||
export type GetArtistInfoResponse = SubsonicResponse & {
|
||||
artistInfo: artistInfo;
|
||||
artistInfo2: artistInfo;
|
||||
};
|
||||
|
||||
export type GetArtistResponse = SubsonicResponse & {
|
||||
@@ -197,12 +197,12 @@ export type GetPlaylistsResponse = {
|
||||
};
|
||||
|
||||
export type GetSimilarSongsResponse = {
|
||||
similarSongs: { song: song[] }
|
||||
}
|
||||
similarSongs2: { song: song[] };
|
||||
};
|
||||
|
||||
export type GetTopSongsResponse = {
|
||||
topSongs: { song: song[] }
|
||||
}
|
||||
topSongs: { song: song[] };
|
||||
};
|
||||
|
||||
export type GetSongResponse = {
|
||||
song: song;
|
||||
@@ -236,7 +236,7 @@ export type getAlbumListParams = {
|
||||
genre?: string;
|
||||
};
|
||||
|
||||
const MAX_ALBUM_LIST = 500;
|
||||
export const MAX_ALBUM_LIST = 500;
|
||||
|
||||
const asTrack = (album: Album, song: song) => ({
|
||||
id: song._id,
|
||||
@@ -248,7 +248,7 @@ const asTrack = (album: Album, song: song) => ({
|
||||
album,
|
||||
artist: {
|
||||
id: song._artistId,
|
||||
name: song._artist
|
||||
name: song._artist,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -349,25 +349,25 @@ export class Navidrome implements MusicService {
|
||||
new X2JS({
|
||||
arrayAccessFormPaths: [
|
||||
"subsonic-response.album.song",
|
||||
"subsonic-response.albumList.album",
|
||||
"subsonic-response.albumList2.album",
|
||||
"subsonic-response.artist.album",
|
||||
"subsonic-response.artists.index",
|
||||
"subsonic-response.artists.index.artist",
|
||||
"subsonic-response.artistInfo.similarArtist",
|
||||
"subsonic-response.artistInfo2.similarArtist",
|
||||
"subsonic-response.genres.genre",
|
||||
"subsonic-response.playlist.entry",
|
||||
"subsonic-response.playlists.playlist",
|
||||
"subsonic-response.searchResult3.album",
|
||||
"subsonic-response.searchResult3.artist",
|
||||
"subsonic-response.searchResult3.song",
|
||||
"subsonic-response.similarSongs.song",
|
||||
"subsonic-response.similarSongs2.song",
|
||||
"subsonic-response.topSongs.song",
|
||||
],
|
||||
}).xml2js(response.data) as SubconicEnvelope
|
||||
)
|
||||
.then((json) => json["subsonic-response"])
|
||||
.then((json) => {
|
||||
if (isError(json)) throw json.error._message;
|
||||
if (isError(json)) throw `Navidrome error:${json.error._message}`;
|
||||
else return json as unknown as T;
|
||||
});
|
||||
|
||||
@@ -389,28 +389,31 @@ export class Navidrome implements MusicService {
|
||||
)
|
||||
);
|
||||
|
||||
getArtists = (credentials: Credentials): Promise<IdName[]> =>
|
||||
getArtists = (
|
||||
credentials: Credentials
|
||||
): Promise<(IdName & { albumCount: number })[]> =>
|
||||
this.getJSON<GetArtistsResponse>(credentials, "/rest/getArtists")
|
||||
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
||||
.then((artists) =>
|
||||
artists.map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
albumCount: Number.parseInt(artist._albumCount),
|
||||
}))
|
||||
);
|
||||
|
||||
getArtistInfo = (credentials: Credentials, id: string): Promise<ArtistInfo> =>
|
||||
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
|
||||
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo2", {
|
||||
id,
|
||||
count: 50,
|
||||
includeNotPresent: true
|
||||
includeNotPresent: true,
|
||||
}).then((it) => ({
|
||||
image: {
|
||||
small: validate(it.artistInfo.smallImageUrl),
|
||||
medium: validate(it.artistInfo.mediumImageUrl),
|
||||
large: validate(it.artistInfo.largeImageUrl),
|
||||
small: validate(it.artistInfo2.smallImageUrl),
|
||||
medium: validate(it.artistInfo2.mediumImageUrl),
|
||||
large: validate(it.artistInfo2.largeImageUrl),
|
||||
},
|
||||
similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({
|
||||
similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({
|
||||
id: artist._id,
|
||||
name: artist._name,
|
||||
inLibrary: artist._id != "-1",
|
||||
@@ -516,28 +519,38 @@ export class Navidrome implements MusicService {
|
||||
})),
|
||||
artist: async (id: string): Promise<Artist> =>
|
||||
navidrome.getArtistWithInfo(credentials, id),
|
||||
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||
navidrome
|
||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
|
||||
...pick(q, "type", "genre"),
|
||||
size: Math.min(MAX_ALBUM_LIST, q._count),
|
||||
offset: q._index,
|
||||
})
|
||||
.then((response) => response.albumList.album || [])
|
||||
.then(navidrome.toAlbumSummary)
|
||||
.then(slice2(q))
|
||||
.then(([page, total]) => ({
|
||||
results: page,
|
||||
total: Math.min(MAX_ALBUM_LIST, total),
|
||||
})),
|
||||
albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> => {
|
||||
return Promise.all([
|
||||
navidrome
|
||||
.getArtists(credentials)
|
||||
.then((it) =>
|
||||
_.inject(it, (total, artist) => total + artist.albumCount, 0)
|
||||
),
|
||||
navidrome
|
||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||
...pick(q, "type", "genre"),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
.then((response) => response.albumList2.album || [])
|
||||
.then(navidrome.toAlbumSummary),
|
||||
]).then(([total, albums]) => ({
|
||||
results: albums.slice(0, q._count),
|
||||
total:
|
||||
albums.length == 500
|
||||
? total
|
||||
: q._index + albums.length,
|
||||
}));
|
||||
},
|
||||
album: (id: string): Promise<Album> =>
|
||||
navidrome.getAlbum(credentials, id),
|
||||
genres: () =>
|
||||
navidrome
|
||||
.getJSON<GenGenresResponse>(credentials, "/rest/getGenres")
|
||||
.getJSON<GetGenresResponse>(credentials, "/rest/getGenres")
|
||||
.then((it) =>
|
||||
pipe(
|
||||
it.genres.genre,
|
||||
it.genres.genre || [],
|
||||
A.filter((it) => Number.parseInt(it._albumCount) > 0),
|
||||
A.map((it) => it.__text),
|
||||
A.sort(ordString),
|
||||
A.map((it) => ({ id: it, name: it }))
|
||||
@@ -711,7 +724,7 @@ export class Navidrome implements MusicService {
|
||||
},
|
||||
artist: {
|
||||
id: entry._artistId,
|
||||
name: entry._artist
|
||||
name: entry._artist,
|
||||
},
|
||||
})),
|
||||
};
|
||||
@@ -743,24 +756,41 @@ export class Navidrome implements MusicService {
|
||||
songIndexToRemove: indicies,
|
||||
})
|
||||
.then((_) => true),
|
||||
similarSongs: async (id: string) => navidrome
|
||||
.getJSON<GetSimilarSongsResponse>(credentials, "/rest/getSimilarSongs", { id, count: 50 })
|
||||
.then((it) => (it.similarSongs.song || []))
|
||||
.then(songs =>
|
||||
Promise.all(
|
||||
songs.map((song) => navidrome.getAlbum(credentials, song._albumId).then(album => asTrack(album, song)))
|
||||
similarSongs: async (id: string) =>
|
||||
navidrome
|
||||
.getJSON<GetSimilarSongsResponse>(
|
||||
credentials,
|
||||
"/rest/getSimilarSongs2",
|
||||
{ id, count: 50 }
|
||||
)
|
||||
),
|
||||
topSongs: async (artistId: string) => navidrome
|
||||
.getArtist(credentials, artistId)
|
||||
.then(({ name }) => navidrome
|
||||
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", { artist: name, count: 50 })
|
||||
.then((it) => (it.topSongs.song || []))
|
||||
.then(songs =>
|
||||
.then((it) => it.similarSongs2.song || [])
|
||||
.then((songs) =>
|
||||
Promise.all(
|
||||
songs.map((song) => navidrome.getAlbum(credentials, song._albumId).then(album => asTrack(album, song)))
|
||||
songs.map((song) =>
|
||||
navidrome
|
||||
.getAlbum(credentials, song._albumId)
|
||||
.then((album) => asTrack(album, song))
|
||||
)
|
||||
)
|
||||
))
|
||||
),
|
||||
topSongs: async (artistId: string) =>
|
||||
navidrome.getArtist(credentials, artistId).then(({ name }) =>
|
||||
navidrome
|
||||
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", {
|
||||
artist: name,
|
||||
count: 50,
|
||||
})
|
||||
.then((it) => it.topSongs.song || [])
|
||||
.then((songs) =>
|
||||
Promise.all(
|
||||
songs.map((song) =>
|
||||
navidrome
|
||||
.getAlbum(credentials, song._albumId)
|
||||
.then((album) => asTrack(album, song))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
return Promise.resolve(musicLibrary);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import registrar from "./registrar";
|
||||
import readConfig from "./config";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
const params = process.argv.slice(2);
|
||||
@@ -9,7 +10,10 @@ if (params.length != 1) {
|
||||
}
|
||||
|
||||
const bonobUrl = new URLBuilder(params[0]!);
|
||||
registrar(bonobUrl)()
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
registrar(bonobUrl, config.sonos.discovery)()
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
console.log(`Successfully registered bonob @ ${bonobUrl} with sonos`);
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import axios from "axios";
|
||||
import _ from "underscore";
|
||||
import logger from "./logger";
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import sonos, { bonobService, Discovery } from "./sonos";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export default (bonobUrl: URLBuilder) => async () => {
|
||||
const about = bonobUrl.append({ pathname: "/about" });
|
||||
logger.info(`Fetching bonob service about from ${about}`);
|
||||
return axios
|
||||
.get(about.href())
|
||||
.then((res) => {
|
||||
if (res.status == 200) return res.data;
|
||||
else throw `Unexpected response status ${res.status} from ${about}`;
|
||||
})
|
||||
.then((about) =>
|
||||
bonobService(about.service.name, about.service.sid, bonobUrl)
|
||||
)
|
||||
.then((bonobService) => sonos(true).register(bonobService));
|
||||
};
|
||||
export default (
|
||||
bonobUrl: URLBuilder,
|
||||
sonosDiscovery: Discovery = {
|
||||
auto: true,
|
||||
seedHost: undefined,
|
||||
}
|
||||
) =>
|
||||
async () => {
|
||||
const about = bonobUrl.append({ pathname: "/about" });
|
||||
logger.info(`Fetching bonob service about from ${about}`);
|
||||
return axios
|
||||
.get(about.href())
|
||||
.then((res) => {
|
||||
if (res.status == 200) return res.data;
|
||||
else throw `Unexpected response status ${res.status} from ${about}`;
|
||||
})
|
||||
.then((res) => {
|
||||
const name = _.get(res, ["service", "name"]);
|
||||
const sid = _.get(res, ["service", "sid"]);
|
||||
if (!name || !sid) {
|
||||
throw `Unexpected response from ${about.href()}, expected service.name and service.sid`;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
sid: Number.parseInt(sid),
|
||||
};
|
||||
})
|
||||
.then(({ name, sid }: { name: string; sid: number }) =>
|
||||
bonobService(name, sid, bonobUrl)
|
||||
)
|
||||
.then((service) => sonos(sonosDiscovery).register(service));
|
||||
};
|
||||
|
||||
254
src/server.ts
@@ -1,7 +1,8 @@
|
||||
import { option as O } from "fp-ts";
|
||||
import express, { Express, Request } from "express";
|
||||
import * as Eta from "eta";
|
||||
import morgan from "morgan";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { PassThrough, Transform, TransformCallback } from "stream";
|
||||
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES,
|
||||
LOGIN_ROUTE,
|
||||
CREATE_REGISTRATION_ROUTE,
|
||||
REMOVE_REGISTRATION_ROUTE
|
||||
REMOVE_REGISTRATION_ROUTE,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -24,8 +25,12 @@ 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 { Icon, ICONS, festivals, features } from "./icon";
|
||||
import _, { shuffle } from "underscore";
|
||||
import morgan from "morgan";
|
||||
import { takeWithRepeats } from "./utils";
|
||||
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
|
||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||
|
||||
interface RangeFilter extends Transform {
|
||||
range: (length: number) => string;
|
||||
@@ -64,20 +69,48 @@ export class RangeBytesFromFilter extends Transform {
|
||||
range = (number: number) => `${this.from}-${number - 1}/${number}`;
|
||||
}
|
||||
|
||||
export type ServerOpts = {
|
||||
linkCodes: () => LinkCodes;
|
||||
accessTokens: () => AccessTokens;
|
||||
clock: Clock;
|
||||
iconColors: {
|
||||
foregroundColor: string | undefined;
|
||||
backgroundColor: string | undefined;
|
||||
};
|
||||
applyContextPath: boolean;
|
||||
logRequests: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
linkCodes: () => new InMemoryLinkCodes(),
|
||||
accessTokens: () => new AccessTokenPerAuthToken(),
|
||||
clock: SystemClock,
|
||||
iconColors: { foregroundColor: undefined, backgroundColor: undefined },
|
||||
applyContextPath: true,
|
||||
logRequests: false,
|
||||
version: "v?",
|
||||
};
|
||||
|
||||
function server(
|
||||
sonos: Sonos,
|
||||
service: Service,
|
||||
bonobUrl: URLBuilder,
|
||||
musicService: MusicService,
|
||||
linkCodes: LinkCodes = new InMemoryLinkCodes(),
|
||||
accessTokens: AccessTokens = new AccessTokenPerAuthToken(),
|
||||
clock: Clock = SystemClock,
|
||||
applyContextPath = true
|
||||
opts: Partial<ServerOpts> = {}
|
||||
): Express {
|
||||
const serverOpts = { ...DEFAULT_SERVER_OPTS, ...opts };
|
||||
|
||||
const linkCodes = serverOpts.linkCodes();
|
||||
const accessTokens = serverOpts.accessTokens();
|
||||
const clock = serverOpts.clock;
|
||||
|
||||
const app = express();
|
||||
const i8n = makeI8N(service.name);
|
||||
|
||||
app.use(morgan("combined"));
|
||||
if (serverOpts.logRequests) {
|
||||
app.use(morgan("combined"));
|
||||
}
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// todo: pass options in here?
|
||||
@@ -85,9 +118,16 @@ function server(
|
||||
app.engine("eta", Eta.renderFile);
|
||||
|
||||
app.set("view engine", "eta");
|
||||
app.set("views", "./web/views");
|
||||
app.set("views", path.resolve(__dirname, "..", "web", "views"));
|
||||
|
||||
const langFor = (req: Request) => i8n(...asLANGs(req.headers["accept-language"]))
|
||||
app.set("query parser", "simple");
|
||||
|
||||
const langFor = (req: Request) => {
|
||||
logger.debug(
|
||||
`${req.path} (req[accept-language]=${req.headers["accept-language"]})`
|
||||
);
|
||||
return i8n(...asLANGs(req.headers["accept-language"]));
|
||||
};
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
const lang = langFor(req);
|
||||
@@ -102,8 +142,13 @@ function server(
|
||||
services,
|
||||
bonobService: service,
|
||||
registeredBonobService,
|
||||
createRegistrationRoute: bonobUrl.append({ pathname: CREATE_REGISTRATION_ROUTE }).pathname(),
|
||||
removeRegistrationRoute: bonobUrl.append({ pathname: REMOVE_REGISTRATION_ROUTE }).pathname(),
|
||||
createRegistrationRoute: bonobUrl
|
||||
.append({ pathname: CREATE_REGISTRATION_ROUTE })
|
||||
.pathname(),
|
||||
removeRegistrationRoute: bonobUrl
|
||||
.append({ pathname: REMOVE_REGISTRATION_ROUTE })
|
||||
.pathname(),
|
||||
version: opts.version,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -113,8 +158,8 @@ function server(
|
||||
return res.send({
|
||||
service: {
|
||||
name: service.name,
|
||||
sid: service.sid
|
||||
}
|
||||
sid: service.sid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,15 +229,19 @@ function server(
|
||||
res.status(403).render("failure", {
|
||||
lang,
|
||||
message: lang("loginFailed"),
|
||||
cause: authResult.message
|
||||
cause: authResult.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get(STRINGS_ROUTE, (_, res) => {
|
||||
const stringNode = (id: string, value: string) => `<string stringId="${id}"><![CDATA[${value}]]></string>`
|
||||
const stringtableNode = (langName: string) => `<stringtable rev="1" xml:lang="${langName}">${i8nKeys().map(key => stringNode(key, i8n(langName as LANG)(key as KEY))).join("")}</stringtable>`
|
||||
const stringNode = (id: string, value: string) =>
|
||||
`<string stringId="${id}"><![CDATA[${value}]]></string>`;
|
||||
const stringtableNode = (langName: string) =>
|
||||
`<stringtable rev="1" xml:lang="${langName}">${i8nKeys()
|
||||
.map((key) => stringNode(key, i8n(langName as LANG)(key as KEY)))
|
||||
.join("")}</stringtable>`;
|
||||
|
||||
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
|
||||
<stringtables xmlns="http://sonos.com/sonosapi">
|
||||
@@ -208,12 +257,23 @@ function server(
|
||||
<Match>
|
||||
<imageSizeMap>
|
||||
${SONOS_RECOMMENDED_IMAGE_SIZES.map(
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/art/size/${size}"/>`
|
||||
).join("")}
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/size/${size}"/>`
|
||||
).join("")}
|
||||
</imageSizeMap>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="BrowseIconSizeMap">
|
||||
<Match>
|
||||
<browseIconSizeMap>
|
||||
<sizeEntry size="0" substitution="/size/legacy"/>
|
||||
${SONOS_RECOMMENDED_IMAGE_SIZES.map(
|
||||
(size) =>
|
||||
`<sizeEntry size="${size}" substitution="/size/${size}"/>`
|
||||
).join("")}
|
||||
</browseIconSizeMap>
|
||||
</Match>
|
||||
</PresentationMap>
|
||||
<PresentationMap type="Search">
|
||||
<Match>
|
||||
<SearchCategories>
|
||||
@@ -252,7 +312,8 @@ function server(
|
||||
)
|
||||
.then(({ musicLibrary, stream }) => {
|
||||
logger.info(
|
||||
`stream response from music service for ${id}, status=${stream.status
|
||||
`stream response from music service for ${id}, status=${
|
||||
stream.status
|
||||
}, headers=(${JSON.stringify(stream.headers)})`
|
||||
);
|
||||
|
||||
@@ -325,44 +386,141 @@ function server(
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/stream/artistRadio/:id", async (req, res) => {
|
||||
const id = req.params["id"]!;
|
||||
console.log(`----------> Streaming artist radio!! ${id}`)
|
||||
res.status(404).send()
|
||||
app.get("/icon/:type/size/:size", (req, res) => {
|
||||
const type = req.params["type"]!;
|
||||
const size = req.params["size"]!;
|
||||
|
||||
if (!Object.keys(ICONS).includes(type)) {
|
||||
return res.status(404).send();
|
||||
} else if (
|
||||
size != "legacy" &&
|
||||
!SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)
|
||||
) {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
let icon = (ICONS as any)[type]! as Icon;
|
||||
const spec =
|
||||
size == "legacy"
|
||||
? {
|
||||
mimeType: "image/png",
|
||||
responseFormatter: (svg: string): Promise<Buffer | string> =>
|
||||
sharp(Buffer.from(svg)).resize(80).png().toBuffer(),
|
||||
}
|
||||
: {
|
||||
mimeType: "image/svg+xml",
|
||||
responseFormatter: (svg: string): Promise<Buffer | string> =>
|
||||
Promise.resolve(svg),
|
||||
};
|
||||
|
||||
return Promise.resolve(
|
||||
icon
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 80,
|
||||
...serverOpts.iconColors,
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock))
|
||||
.toString()
|
||||
)
|
||||
.then(spec.responseFormatter)
|
||||
.then((data) => res.status(200).type(spec.mimeType).send(data));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/:type/:id/art/size/:size", (req, res) => {
|
||||
app.get("/icons", (_, res) => {
|
||||
res.render("icons", {
|
||||
icons: Object.keys(ICONS).map((k) => [
|
||||
k,
|
||||
((ICONS as any)[k] as Icon)
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 80,
|
||||
...serverOpts.iconColors,
|
||||
})
|
||||
)
|
||||
.toString()
|
||||
.replace('<?xml version="1.0" encoding="UTF-8"?>', ""),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const GRAVITY_9 = [
|
||||
"north",
|
||||
"northeast",
|
||||
"east",
|
||||
"southeast",
|
||||
"south",
|
||||
"southwest",
|
||||
"west",
|
||||
"northwest",
|
||||
"centre",
|
||||
];
|
||||
|
||||
app.get("/art/:type/:ids/size/:size", (req, res) => {
|
||||
const authToken = accessTokens.authTokenFor(
|
||||
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||
);
|
||||
const type = req.params["type"]!;
|
||||
const id = req.params["id"]!;
|
||||
const ids = req.params["ids"]!.split("&");
|
||||
const size = Number.parseInt(req.params["size"]!);
|
||||
|
||||
if (!authToken) {
|
||||
return res.status(401).send();
|
||||
} else if (type != "artist" && type != "album") {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => it.coverArt(id, type, size))
|
||||
.then((coverArt) => {
|
||||
if (coverArt) {
|
||||
res.status(200);
|
||||
res.setHeader("content-type", coverArt.contentType);
|
||||
res.send(coverArt.data);
|
||||
} else {
|
||||
res.status(404).send();
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logger.error(
|
||||
`Failed fetching image ${type}/${id}/size/${size}: ${e.message}`,
|
||||
e
|
||||
);
|
||||
res.status(500).send();
|
||||
});
|
||||
} else if (!(size > 0)) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, type, size))))
|
||||
.then((coverArts) => coverArts.filter((it) => it))
|
||||
.then(shuffle)
|
||||
.then((coverArts) => {
|
||||
if (coverArts.length == 1) {
|
||||
const coverArt = coverArts[0]!;
|
||||
res.status(200);
|
||||
res.setHeader("content-type", coverArt.contentType);
|
||||
return res.send(coverArt.data);
|
||||
} else if (coverArts.length > 1) {
|
||||
const gravity = [...GRAVITY_9];
|
||||
return sharp({
|
||||
create: {
|
||||
width: size * 3,
|
||||
height: size * 3,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 255, b: 255 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
takeWithRepeats(coverArts, 9).map((art) => ({
|
||||
input: art?.data,
|
||||
gravity: gravity.pop(),
|
||||
}))
|
||||
)
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((image) => sharp(image).resize(size).png().toBuffer())
|
||||
.then((image) => {
|
||||
res.status(200);
|
||||
res.setHeader("content-type", "image/png");
|
||||
return res.send(image);
|
||||
});
|
||||
} else {
|
||||
return res.status(404).send();
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logger.error(
|
||||
`Failed fetching image ${type}/${ids.join("&")}/size/${size}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
return res.status(500).send();
|
||||
});
|
||||
});
|
||||
|
||||
bindSmapiSoapServiceToExpress(
|
||||
@@ -376,7 +534,7 @@ function server(
|
||||
i8n
|
||||
);
|
||||
|
||||
if (applyContextPath) {
|
||||
if (serverOpts.applyContextPath) {
|
||||
const container = express();
|
||||
container.use(bonobUrl.path(), app);
|
||||
return container;
|
||||
|
||||
212
src/smapi.ts
@@ -5,7 +5,6 @@ import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import logger from "./logger";
|
||||
|
||||
|
||||
import { LinkCodes } from "./link_codes";
|
||||
import {
|
||||
Album,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
ArtistSummary,
|
||||
Genre,
|
||||
MusicService,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
@@ -22,7 +21,9 @@ import { AccessTokens } from "./access_tokens";
|
||||
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
|
||||
import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
import { I8N, LANG } from "./i8n";
|
||||
import { asLANGs, I8N } from "./i8n";
|
||||
import { ICON, iconForGenre } from "./icon";
|
||||
import { uniq } from "underscore";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||
@@ -183,11 +184,14 @@ class SonosSoap {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
logger.info("Client not linked, awaiting user to associate account with link code by logging in.")
|
||||
logger.info(
|
||||
"Client not linked, awaiting user to associate account with link code by logging in."
|
||||
);
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.NOT_LINKED_RETRY",
|
||||
faultstring: "Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
faultstring:
|
||||
"Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
detail: {
|
||||
ExceptionInfo: "NOT_LINKED_RETRY",
|
||||
SonosError: "5",
|
||||
@@ -207,16 +211,21 @@ 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)
|
||||
).href(),
|
||||
});
|
||||
|
||||
const playlist = (playlist: PlaylistSummary) => ({
|
||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
title: playlist.name,
|
||||
albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(),
|
||||
canPlay: true,
|
||||
attributes: {
|
||||
readOnly: false,
|
||||
@@ -225,19 +234,41 @@ const playlist = (playlist: PlaylistSummary) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const playlistAlbumArtURL = (
|
||||
bonobUrl: URLBuilder,
|
||||
playlist: Playlist
|
||||
) => {
|
||||
const ids = uniq(playlist.entries.map((it) => it.album?.id).filter((it) => it));
|
||||
if (ids.length == 0) {
|
||||
return iconArtURI(bonobUrl, "error");
|
||||
} else {
|
||||
return bonobUrl.append({
|
||||
pathname: `/art/album/${ids.slice(0, 9).join("&")}/size/180`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||
bonobUrl.append({ pathname: `/album/${album.id}/art/size/180` });
|
||||
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 defaultArtistArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
artist: ArtistSummary
|
||||
) => bonobUrl.append({ pathname: `/artist/${artist.id}/art/size/180` });
|
||||
) => bonobUrl.append({ pathname: `/art/artist/${artist.id}/size/180` });
|
||||
|
||||
export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
itemType: "album",
|
||||
id: `album:${album.id}`,
|
||||
artist: album.artistName,
|
||||
artistId: album.artistId,
|
||||
artistId: `artist:${album.artistId}`,
|
||||
title: album.name,
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, album).href(),
|
||||
canPlay: true,
|
||||
@@ -337,7 +368,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
searchParams: {
|
||||
"bonob-access-token": accessToken,
|
||||
"bat": accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,7 +392,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaURI: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -381,39 +412,19 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaMetadata: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, accessToken, type, typeId }) => {
|
||||
console.log(`!!! getMediaMetadata->${id}`)
|
||||
switch (type) {
|
||||
case "track": return musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(
|
||||
urlWithToken(accessToken),
|
||||
it,
|
||||
),
|
||||
}));
|
||||
case "artistRadio": return {
|
||||
getMediaMetadataResult: {
|
||||
id,
|
||||
itemType: "stream",
|
||||
title: "Foobar100",
|
||||
mimeType: 'audio/x-scpls',
|
||||
// streamMetadata: {
|
||||
// logo: "??"
|
||||
// }
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw `Unsupported search by:${id}`;
|
||||
}
|
||||
}
|
||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||
musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(urlWithToken(accessToken), it),
|
||||
}))
|
||||
),
|
||||
search: async (
|
||||
{ id, term }: { id: string; term: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -456,9 +467,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
index,
|
||||
count,
|
||||
}: // recursive,
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -479,20 +490,15 @@ function bindSmapiSoapServiceToExpress(
|
||||
album(urlWithToken(accessToken), it)
|
||||
),
|
||||
relatedBrowse:
|
||||
artist.similarArtists.filter(it => it.inLibrary).length > 0
|
||||
artist.similarArtists.filter((it) => it.inLibrary)
|
||||
.length > 0
|
||||
? [
|
||||
{
|
||||
id: `relatedArtists:${artist.id}`,
|
||||
type: "RELATED_ARTISTS",
|
||||
},
|
||||
]
|
||||
{
|
||||
id: `relatedArtists:${artist.id}`,
|
||||
type: "RELATED_ARTISTS",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
relatedPlay: {
|
||||
id: `artistRadio:${artist.id}`,
|
||||
itemType: "stream",
|
||||
title: "Foobar radio",
|
||||
canPlay: true
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -550,19 +556,20 @@ function bindSmapiSoapServiceToExpress(
|
||||
index,
|
||||
count,
|
||||
}: // recursive,
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, 'headers'>
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, accessToken, type, typeId }) => {
|
||||
const paging = { _index: index, _count: count };
|
||||
const lang = i8n((headers["accept-language"] || "en-US") as LANG);
|
||||
const acceptLanguage = headers["accept-language"];
|
||||
logger.debug(
|
||||
`Fetching metadata type=${type}, typeId=${typeId}`
|
||||
`Fetching metadata type=${type}, typeId=${typeId}, acceptLanguage=${acceptLanguage}`
|
||||
);
|
||||
const lang = i8n(...asLANGs(acceptLanguage));
|
||||
|
||||
const albums = (q: AlbumQuery): Promise<GetMetadataResponse> =>
|
||||
musicLibrary.albums(q).then((result) => {
|
||||
@@ -580,19 +587,22 @@ function bindSmapiSoapServiceToExpress(
|
||||
return getMetadataResult({
|
||||
mediaCollection: [
|
||||
{
|
||||
itemType: "container",
|
||||
id: "artists",
|
||||
title: lang("artists"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "albums",
|
||||
title: lang("albums"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "playlist",
|
||||
id: "playlists",
|
||||
title: lang("playlists"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
|
||||
itemType: "playlist",
|
||||
attributes: {
|
||||
readOnly: false,
|
||||
userContent: true,
|
||||
@@ -600,34 +610,49 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
},
|
||||
{
|
||||
itemType: "container",
|
||||
id: "genres",
|
||||
title: lang("genres"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "randomAlbums",
|
||||
title: lang("random"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "starredAlbums",
|
||||
title: lang("starred"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyAdded"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyPlayed",
|
||||
title: lang("recentlyPlayed"),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "mostPlayed",
|
||||
title: lang("mostPlayed"),
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"mostPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
@@ -636,9 +661,21 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "search":
|
||||
return getMetadataResult({
|
||||
mediaCollection: [
|
||||
{ itemType: "search", id: "artists", title: lang("artists") },
|
||||
{ itemType: "search", id: "albums", title: lang("albums") },
|
||||
{ itemType: "search", id: "tracks", title: lang("tracks") },
|
||||
{
|
||||
itemType: "search",
|
||||
id: "artists",
|
||||
title: lang("artists"),
|
||||
},
|
||||
{
|
||||
itemType: "search",
|
||||
id: "albums",
|
||||
title: lang("albums"),
|
||||
},
|
||||
{
|
||||
itemType: "search",
|
||||
id: "tracks",
|
||||
title: lang("tracks"),
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
total: 3,
|
||||
@@ -655,7 +692,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
});
|
||||
case "albums": {
|
||||
return albums({
|
||||
type: "alphabeticalByArtist",
|
||||
type: "alphabeticalByName",
|
||||
...paging,
|
||||
});
|
||||
}
|
||||
@@ -696,7 +733,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map(genre),
|
||||
mediaCollection: page.map((it) =>
|
||||
genre(bonobUrl, it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
@@ -704,14 +743,23 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "playlists":
|
||||
return musicLibrary
|
||||
.playlists()
|
||||
.then((it) =>
|
||||
Promise.all(
|
||||
it.map((playlist) =>
|
||||
musicLibrary.playlist(playlist.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map(playlist),
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
playlist(urlWithToken(accessToken), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
case "playlist":
|
||||
return musicLibrary
|
||||
.playlist(typeId!)
|
||||
@@ -744,7 +792,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
return musicLibrary
|
||||
.artist(typeId!)
|
||||
.then((artist) => artist.similarArtists)
|
||||
.then(similarArtists => similarArtists.filter(it => it.inLibrary))
|
||||
.then((similarArtists) =>
|
||||
similarArtists.filter((it) => it.inLibrary)
|
||||
)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
@@ -775,7 +825,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
createContainer: async (
|
||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) =>
|
||||
@@ -801,7 +851,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
deleteContainer: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||
@@ -809,7 +859,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
addToContainer: async (
|
||||
{ id, parentId }: { id: string; parentId: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -820,7 +870,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
removeFromContainer: async (
|
||||
{ id, indices }: { id: string; indices: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
@@ -843,7 +893,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
setPlayedSeconds: async (
|
||||
{ id, seconds }: { id: string; seconds: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
auth(musicService, accessTokens, soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
|
||||
53
src/sonos.ts
@@ -7,10 +7,24 @@ import logger from "./logger";
|
||||
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi";
|
||||
import qs from "querystring";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
import { LANG } from "./i8n";
|
||||
|
||||
export const SONOS_LANG = ["en-US", "da-DK", "de-DE", "es-ES", "fr-FR", "it-IT", "ja-JP", "nb-NO", "nl-NL", "pt-BR", "sv-SE", "zh-CN"]
|
||||
export const SONOS_LANG: LANG[] = [
|
||||
"en-US",
|
||||
"da-DK",
|
||||
"de-DE",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"it-IT",
|
||||
"ja-JP",
|
||||
"nb-NO",
|
||||
"nl-NL",
|
||||
"pt-BR",
|
||||
"sv-SE",
|
||||
"zh-CN",
|
||||
];
|
||||
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "20";
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "21";
|
||||
|
||||
// NOTE: manifest requires https for the URL,
|
||||
// otherwise you will get an error trying to register
|
||||
@@ -20,7 +34,6 @@ export type Capability =
|
||||
| "alFavorites"
|
||||
| "ucPlaylists"
|
||||
| "extendedMD"
|
||||
| "radioExtendedMD"
|
||||
| "contextHeaders"
|
||||
| "authorizationHeader"
|
||||
| "logging"
|
||||
@@ -33,7 +46,6 @@ export const BONOB_CAPABILITIES: Capability[] = [
|
||||
"ucPlaylists",
|
||||
"extendedMD",
|
||||
"logging",
|
||||
"radioExtendedMD"
|
||||
];
|
||||
|
||||
export type Device = {
|
||||
@@ -119,7 +131,7 @@ export const asDevice = (sonosDevice: SonosDevice): Device => ({
|
||||
|
||||
export const asRemoveCustomdForm = (csrfToken: string, sid: number) => ({
|
||||
csrfToken,
|
||||
sid: `${sid}`
|
||||
sid: `${sid}`,
|
||||
});
|
||||
|
||||
export const asCustomdForm = (csrfToken: string, service: Service) => ({
|
||||
@@ -164,12 +176,15 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`Failed looking for sonos devices ${e}`);
|
||||
logger.error(`Failed looking for sonos devices`, { cause: e });
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const post = async (action: string, customdForm: (csrfToken: string) => any) => {
|
||||
const post = async (
|
||||
action: string,
|
||||
customdForm: (csrfToken: string) => any
|
||||
) => {
|
||||
const anyDevice = await sonosDevices().then((devices) => head(devices));
|
||||
|
||||
if (!anyDevice) {
|
||||
@@ -196,7 +211,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const form = customdForm(csrfToken)
|
||||
const form = customdForm(csrfToken);
|
||||
logger.info(`${action} with sonos @ ${customd}`, { form });
|
||||
return axios
|
||||
.post(customd, new URLSearchParams(qs.stringify(form)), {
|
||||
@@ -219,16 +234,22 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
)
|
||||
.then((it) => it.map(asService)),
|
||||
|
||||
remove: async (sid: number) => post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)),
|
||||
remove: async (sid: number) =>
|
||||
post("remove", (csrfToken) => asRemoveCustomdForm(csrfToken, sid)),
|
||||
|
||||
register: async (service: Service) => post("register", (csrfToken) => asCustomdForm(csrfToken, service)),
|
||||
register: async (service: Service) =>
|
||||
post("register", (csrfToken) => asCustomdForm(csrfToken, service)),
|
||||
};
|
||||
}
|
||||
|
||||
const sonos = (
|
||||
discoveryEnabled: boolean = true,
|
||||
sonosSeedHost: string | undefined = undefined
|
||||
): Sonos =>
|
||||
discoveryEnabled ? autoDiscoverySonos(sonosSeedHost) : SONOS_DISABLED;
|
||||
export type Discovery = {
|
||||
auto: boolean;
|
||||
seedHost?: string;
|
||||
};
|
||||
|
||||
export default sonos;
|
||||
export default (
|
||||
sonosDiscovery: Discovery = { auto: true }
|
||||
): Sonos =>
|
||||
sonosDiscovery.auto
|
||||
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
||||
: SONOS_DISABLED;
|
||||
|
||||
7
src/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||
const result = [];
|
||||
for(let i = 0; i < count; i++) {
|
||||
result.push(things[i % things.length])
|
||||
}
|
||||
return result;
|
||||
}
|
||||
58
tests/clock.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,70 @@ 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");
|
||||
@@ -134,17 +198,17 @@ describe("config", () => {
|
||||
"deviceDiscovery",
|
||||
"BONOB_SONOS_DEVICE_DISCOVERY",
|
||||
true,
|
||||
(config) => config.sonos.deviceDiscovery
|
||||
(config) => config.sonos.discovery.auto
|
||||
);
|
||||
|
||||
describe("seedHost", () => {
|
||||
it("should default to undefined", () => {
|
||||
expect(config().sonos.seedHost).toBeUndefined();
|
||||
expect(config().sonos.discovery.seedHost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0";
|
||||
expect(config().sonos.seedHost).toEqual("123.456.789.0");
|
||||
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import i8n, { langs, LANG, KEY, keys, asLANGs } from "../src/i8n";
|
||||
import i8n, { langs, LANG, KEY, keys, asLANGs, SUPPORTED_LANG } from "../src/i8n";
|
||||
|
||||
describe("i8n", () => {
|
||||
describe("asLANGs", () => {
|
||||
@@ -41,7 +41,7 @@ describe("i8n", () => {
|
||||
describe("validity of translations", () => {
|
||||
it("all langs should have same keys as US", () => {
|
||||
langs().forEach((l) => {
|
||||
expect(keys(l as LANG)).toEqual(keys("en-US"));
|
||||
expect(keys(l as SUPPORTED_LANG)).toEqual(keys("en-US"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,79 +54,129 @@ describe("i8n", () => {
|
||||
|
||||
describe("fetching translations", () => {
|
||||
describe("with a single lang", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the value", () => {
|
||||
expect(i8n("foo")("en-US")("artists")).toEqual("Artists");
|
||||
expect(i8n("foo")("nl-NL")("artists")).toEqual("Artiesten");
|
||||
describe("and the lang is not represented", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the en-US value", () => {
|
||||
expect(i8n("foo")("en-AU" as LANG)("artists")).toEqual("Artists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the en-US value templated", () => {
|
||||
expect(i8n("service123")("en-AU" as LANG)("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the value", () => {
|
||||
expect(i8n("service123")("en-US")("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
expect(i8n("service456")("nl-NL")("AppLinkMessage")).toEqual(
|
||||
"Sonos koppelen aan service456"
|
||||
);
|
||||
|
||||
describe("and the lang is represented", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the value", () => {
|
||||
expect(i8n("foo")("en-US")("artists")).toEqual("Artists");
|
||||
expect(i8n("foo")("nl-NL")("artists")).toEqual("Artiesten");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the value", () => {
|
||||
expect(i8n("service123")("en-US")("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
expect(i8n("service456")("nl-NL")("AppLinkMessage")).toEqual(
|
||||
"Sonos koppelen aan service456"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with multiple langs", () => {
|
||||
describe("and the first lang is a match", () => {
|
||||
function itShouldReturn(serviceName: string, langs: string[], key: KEY, expected: string) {
|
||||
it(`should return '${expected}' for the serviceName=${serviceName}, langs=${langs}`, () => {
|
||||
expect(i8n(serviceName)(...langs)(key)).toEqual(expected);
|
||||
});
|
||||
};
|
||||
|
||||
describe("and the first lang is an exact match", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the value for the first lang", () => {
|
||||
expect(i8n("foo")("en-US", "nl-NL")("artists")).toEqual("Artists");
|
||||
expect(i8n("foo")("nl-NL", "en-US")("artists")).toEqual("Artiesten");
|
||||
});
|
||||
itShouldReturn("foo", ["en-US", "nl-NL"], "artists", "Artists");
|
||||
itShouldReturn("foo", ["nl-NL", "en-US"], "artists", "Artiesten");
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the value for the firt lang", () => {
|
||||
expect(i8n("service123")("en-US", "nl-NL")("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
expect(i8n("service456")("nl-NL", "en-US")("AppLinkMessage")).toEqual(
|
||||
"Sonos koppelen aan service456"
|
||||
);
|
||||
});
|
||||
itShouldReturn("service123", ["en-US", "nl-NL"], "AppLinkMessage", "Linking sonos with service123");
|
||||
itShouldReturn("service456", ["nl-NL", "en-US"], "AppLinkMessage", "Sonos koppelen aan service456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and the first lang is not a match, however there is a match in the provided langs", () => {
|
||||
describe("and the first lang is a case insensitive match", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the value for the first lang", () => {
|
||||
expect(i8n("foo")("something", "en-US", "nl-NL")("artists")).toEqual("Artists");
|
||||
expect(i8n("foo")("something", "nl-NL", "en-US")("artists")).toEqual("Artiesten");
|
||||
});
|
||||
itShouldReturn("foo", ["en-us", "nl-NL"], "artists", "Artists");
|
||||
itShouldReturn("foo", ["nl-nl", "en-US"], "artists", "Artiesten");
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the value for the firt lang", () => {
|
||||
expect(i8n("service123")("something", "en-US", "nl-NL")("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
expect(i8n("service456")("something", "nl-NL", "en-US")("AppLinkMessage")).toEqual(
|
||||
"Sonos koppelen aan service456"
|
||||
);
|
||||
});
|
||||
itShouldReturn("service123", ["en-us", "nl-NL"], "AppLinkMessage", "Linking sonos with service123");
|
||||
itShouldReturn("service456", ["nl-nl", "en-US"], "AppLinkMessage", "Sonos koppelen aan service456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and the first lang is a lang match without region", () => {
|
||||
describe("and there is no templating", () => {
|
||||
itShouldReturn("foo", ["en", "nl-NL"], "artists", "Artists");
|
||||
itShouldReturn("foo", ["nl", "en-US"], "artists", "Artiesten");
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
itShouldReturn("service123", ["en", "nl-NL"], "AppLinkMessage", "Linking sonos with service123");
|
||||
itShouldReturn("service456", ["nl", "en-US"], "AppLinkMessage", "Sonos koppelen aan service456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and the first lang is not a match, however there is an exact match in the provided langs", () => {
|
||||
describe("and there is no templating", () => {
|
||||
itShouldReturn("foo", ["something", "en-US", "nl-NL"], "artists", "Artists")
|
||||
itShouldReturn("foo", ["something", "nl-NL", "en-US"], "artists", "Artiesten")
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
itShouldReturn("service123", ["something", "en-US", "nl-NL"], "AppLinkMessage", "Linking sonos with service123")
|
||||
itShouldReturn("service456", ["something", "nl-NL", "en-US"], "AppLinkMessage", "Sonos koppelen aan service456")
|
||||
});
|
||||
});
|
||||
|
||||
describe("and the first lang is not a match, however there is a case insensitive match in the provided langs", () => {
|
||||
describe("and there is no templating", () => {
|
||||
itShouldReturn("foo", ["something", "en-us", "nl-nl"], "artists", "Artists")
|
||||
itShouldReturn("foo", ["something", "nl-nl", "en-us"], "artists", "Artiesten")
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
itShouldReturn("service123", ["something", "en-us", "nl-nl"], "AppLinkMessage", "Linking sonos with service123")
|
||||
itShouldReturn("service456", ["something", "nl-nl", "en-us"], "AppLinkMessage", "Sonos koppelen aan service456")
|
||||
});
|
||||
});
|
||||
|
||||
describe("and the first lang is not a match, however there is a lang match without region", () => {
|
||||
describe("and there is no templating", () => {
|
||||
itShouldReturn("foo", ["something", "en", "nl-nl"], "artists", "Artists")
|
||||
itShouldReturn("foo", ["something", "nl", "en-us"], "artists", "Artiesten")
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
itShouldReturn("service123", ["something", "en", "nl-nl"], "AppLinkMessage", "Linking sonos with service123")
|
||||
itShouldReturn("service456", ["something", "nl", "en-us"], "AppLinkMessage", "Sonos koppelen aan service456")
|
||||
});
|
||||
});
|
||||
|
||||
describe("and no lang is a match", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the value for the first lang", () => {
|
||||
expect(i8n("foo")("something", "something2")("artists")).toEqual("Artists");
|
||||
});
|
||||
itShouldReturn("foo", ["something", "something2"], "artists", "Artists")
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the value for the firt lang", () => {
|
||||
expect(i8n("service123")("something", "something2")("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
});
|
||||
itShouldReturn("service123", ["something", "something2"], "AppLinkMessage", "Linking sonos with service123")
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,20 +189,5 @@ describe("i8n", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the lang is not represented", () => {
|
||||
describe("and there is no templating", () => {
|
||||
it("should return the en-US value", () => {
|
||||
expect(i8n("foo")("en-AU" as LANG)("artists")).toEqual("Artists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is templating of the service name", () => {
|
||||
it("should return the en-US value templated", () => {
|
||||
expect(i8n("service123")("en-AU" as LANG)("AppLinkMessage")).toEqual(
|
||||
"Linking sonos with service123"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
836
tests/icon.test.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
import dayjs from "dayjs";
|
||||
import libxmljs from "libxmljs2";
|
||||
|
||||
import {
|
||||
contains,
|
||||
containsWord,
|
||||
eq,
|
||||
HOLI_COLORS,
|
||||
Icon,
|
||||
iconForGenre,
|
||||
SvgIcon,
|
||||
IconFeatures,
|
||||
IconSpec,
|
||||
ICONS,
|
||||
Transformer,
|
||||
transform,
|
||||
maybeTransform,
|
||||
festivals,
|
||||
allOf,
|
||||
features,
|
||||
STAR_WARS,
|
||||
} from "../src/icon";
|
||||
|
||||
describe("SvgIcon", () => {
|
||||
const xmlTidy = (xml: string) =>
|
||||
libxmljs.parseXmlString(xml, { noblanks: true, net: false }).toString();
|
||||
|
||||
const svgIcon24 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const svgIcon128 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
describe("with no features", () => {
|
||||
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({ features: { viewPortIncreasePercent: 50 } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("when the viewPort is of size 0 0 128 128", () => {
|
||||
it("should resize the viewPort", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon128)
|
||||
.with({ features: { viewPortIncreasePercent: 50 } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-21 -21 170 170">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("of 0%", () => {
|
||||
it("should do nothing", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { 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({ features: { backgroundColor: "red" } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a viewPort increase", () => {
|
||||
it("should add a rectangle the same size as the original viewPort", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({
|
||||
features: {
|
||||
backgroundColor: "pink",
|
||||
viewPortIncreasePercent: 50,
|
||||
},
|
||||
})
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||
<rect x="-4" y="-4" width="36" height="36" fill="pink"/>
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("of undefined", () => {
|
||||
it("should not do anything", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { backgroundColor: undefined } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple times", () => {
|
||||
it("should use the most recent", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { backgroundColor: "green" } })
|
||||
.with({ features: { backgroundColor: "red" } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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({ features: { foregroundColor: "red" } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1" fill="red"/>
|
||||
<path d="path2" fill="none" stroke="red"/>
|
||||
<path d="path3" fill="red"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a viewPort increase", () => {
|
||||
it("should add a rectangle the same size as the original viewPort", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({
|
||||
features: {
|
||||
foregroundColor: "pink",
|
||||
viewPortIncreasePercent: 50,
|
||||
},
|
||||
})
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||
<path d="path1" fill="pink"/>
|
||||
<path d="path2" fill="none" stroke="pink"/>
|
||||
<path d="path3" fill="pink"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("of undefined", () => {
|
||||
it("should not do anything", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { foregroundColor: undefined } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1"/>
|
||||
<path d="path2" fill="none" stroke="#000"/>
|
||||
<path d="path3"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutliple times", () => {
|
||||
it("should use the most recent", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { foregroundColor: "blue" } })
|
||||
.with({ features: { foregroundColor: "red" } })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1" fill="red"/>
|
||||
<path d="path2" fill="none" stroke="red"/>
|
||||
<path d="path3" fill="red"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("swapping the svg", () => {
|
||||
describe("with no other changes", () => {
|
||||
it("should swap out the svg, but maintain the IconFeatures", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24, {
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "green",
|
||||
viewPortIncreasePercent: 50,
|
||||
})
|
||||
.with({ svg: svgIcon128 })
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-21 -21 170 170">
|
||||
<rect x="-21" y="-21" width="191" height="191" fill="green"/>
|
||||
<path d="path1" fill="blue"/>
|
||||
<path d="path2" fill="none" stroke="blue"/>
|
||||
<path d="path3" fill="blue"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no other changes", () => {
|
||||
it("should swap out the svg, but maintain the IconFeatures", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24, {
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "green",
|
||||
viewPortIncreasePercent: 50,
|
||||
})
|
||||
.with({
|
||||
svg: svgIcon128,
|
||||
features: {
|
||||
foregroundColor: "pink",
|
||||
backgroundColor: "red",
|
||||
viewPortIncreasePercent: 0,
|
||||
},
|
||||
})
|
||||
.toString()
|
||||
).toEqual(
|
||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect x="0" y="0" width="128" height="128" fill="red"/>
|
||||
<path d="path1" fill="pink"/>
|
||||
<path d="path2" fill="none" stroke="pink"/>
|
||||
<path d="path3" fill="pink"/>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class DummyIcon implements Icon {
|
||||
svg: string;
|
||||
features: Partial<IconFeatures>;
|
||||
constructor(svg: string, features: Partial<IconFeatures>) {
|
||||
this.svg = svg;
|
||||
this.features = features;
|
||||
}
|
||||
|
||||
public apply = (transformer: Transformer): Icon => transformer(this);
|
||||
|
||||
public with = ({ svg, features }: Partial<IconSpec>) => {
|
||||
return new DummyIcon(svg || this.svg, {
|
||||
...this.features,
|
||||
...(features || {}),
|
||||
});
|
||||
};
|
||||
|
||||
public toString = () =>
|
||||
JSON.stringify({ svg: this.svg, features: this.features });
|
||||
}
|
||||
|
||||
describe("transform", () => {
|
||||
describe("when the features contains no svg", () => {
|
||||
it("should apply the overriding transform ontop of the requested transform", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
const result = original
|
||||
.with({
|
||||
features: {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
transform({
|
||||
features: {
|
||||
foregroundColor: "override1",
|
||||
backgroundColor: "override2",
|
||||
},
|
||||
})
|
||||
) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual("original");
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "override1",
|
||||
backgroundColor: "override2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the features contains an svg", () => {
|
||||
it("should use the newly provided svg", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
const result = original
|
||||
.with({
|
||||
features: {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
transform({
|
||||
svg: "new",
|
||||
})
|
||||
) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual("new");
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("features", () => {
|
||||
it("should apply the features", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
const result = original.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
})
|
||||
) as DummyIcon;
|
||||
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("allOf", () => {
|
||||
it("should apply all composed transforms", () => {
|
||||
const result = new DummyIcon("original", {
|
||||
foregroundColor: "black",
|
||||
backgroundColor: "black",
|
||||
viewPortIncreasePercent: 0,
|
||||
}).apply(
|
||||
allOf(
|
||||
(icon: Icon) => icon.with({ svg: "foo" }),
|
||||
(icon: Icon) => icon.with({ features: { backgroundColor: "red" } }),
|
||||
(icon: Icon) => icon.with({ features: { foregroundColor: "blue" } })
|
||||
)
|
||||
) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual("foo");
|
||||
expect(result.features).toEqual({
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "red",
|
||||
viewPortIncreasePercent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeTransform", () => {
|
||||
describe("when the rule matches", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
|
||||
describe("transforming the color", () => {
|
||||
const result = original
|
||||
.with({
|
||||
features: {
|
||||
viewPortIncreasePercent: 99,
|
||||
backgroundColor: "shouldBeIgnored",
|
||||
foregroundColor: "shouldBeIgnored",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
maybeTransform(
|
||||
() => true,
|
||||
transform({
|
||||
features: {
|
||||
backgroundColor: "blue",
|
||||
foregroundColor: "red",
|
||||
},
|
||||
})
|
||||
)
|
||||
) as DummyIcon;
|
||||
|
||||
describe("with", () => {
|
||||
it("should be the with of the underlieing icon with the overriden colors", () => {
|
||||
expect(result.svg).toEqual("original");
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 99,
|
||||
backgroundColor: "blue",
|
||||
foregroundColor: "red",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("overriding all options", () => {
|
||||
const result = original
|
||||
.with({
|
||||
features: {
|
||||
viewPortIncreasePercent: 99,
|
||||
backgroundColor: "shouldBeIgnored",
|
||||
foregroundColor: "shouldBeIgnored",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
maybeTransform(
|
||||
() => true,
|
||||
transform({
|
||||
features: {
|
||||
backgroundColor: "blue",
|
||||
foregroundColor: "red",
|
||||
},
|
||||
})
|
||||
)
|
||||
) as DummyIcon;
|
||||
|
||||
describe("with", () => {
|
||||
it("should be the with of the underlieing icon with the overriden colors", () => {
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 99,
|
||||
backgroundColor: "blue",
|
||||
foregroundColor: "red",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the rule doesnt match", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
const result = original
|
||||
.with({
|
||||
features: {
|
||||
viewPortIncreasePercent: 88,
|
||||
backgroundColor: "shouldBeUsed",
|
||||
foregroundColor: "shouldBeUsed",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
maybeTransform(
|
||||
() => false,
|
||||
transform({
|
||||
features: { backgroundColor: "blue", foregroundColor: "red" },
|
||||
})
|
||||
)
|
||||
) as DummyIcon;
|
||||
|
||||
describe("with", () => {
|
||||
it("should use the provided features", () => {
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 88,
|
||||
backgroundColor: "shouldBeUsed",
|
||||
foregroundColor: "shouldBeUsed",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("festivals", () => {
|
||||
const original = new DummyIcon("original", {
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "black",
|
||||
});
|
||||
let now = dayjs();
|
||||
const clock = { now: () => now };
|
||||
|
||||
describe("on a day that isn't festive", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/10/12");
|
||||
});
|
||||
|
||||
it("should use the given colors", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 88,
|
||||
backgroundColor: "shouldBeUsed",
|
||||
foregroundColor: "shouldBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.toString()).toEqual(
|
||||
new DummyIcon("original", {
|
||||
backgroundColor: "shouldBeUsed",
|
||||
foregroundColor: "shouldBeUsed",
|
||||
viewPortIncreasePercent: 88,
|
||||
}).toString()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on christmas day", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/12/25");
|
||||
});
|
||||
|
||||
it("should use the christmas theme colors", () => {
|
||||
const result = original.apply(
|
||||
allOf(
|
||||
features({
|
||||
viewPortIncreasePercent: 25,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
}),
|
||||
festivals(clock)
|
||||
)
|
||||
) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual(ICONS.christmas.svg);
|
||||
expect(result.features).toEqual({
|
||||
backgroundColor: "green",
|
||||
foregroundColor: "red",
|
||||
viewPortIncreasePercent: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on halloween", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/10/31");
|
||||
});
|
||||
|
||||
it("should use the given colors", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual(ICONS.halloween.svg);
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "black",
|
||||
foregroundColor: "orange",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on may 4", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/5/4");
|
||||
});
|
||||
|
||||
it("should use the undefined colors, so no color", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(STAR_WARS.map(it => it.svg)).toContain(result.svg);
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on cny", () => {
|
||||
describe("2022", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/02/01");
|
||||
});
|
||||
|
||||
it("should use the cny theme", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual(ICONS.yoTiger.svg);
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("2023", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2023/01/22");
|
||||
});
|
||||
|
||||
it("should use the cny theme", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual(ICONS.yoRabbit.svg);
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("2024", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2024/02/10");
|
||||
});
|
||||
|
||||
it("should use the cny theme", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.svg).toEqual(ICONS.yoDragon.svg);
|
||||
expect(result.features).toEqual({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "red",
|
||||
foregroundColor: "yellow",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("on holi", () => {
|
||||
beforeEach(() => {
|
||||
now = dayjs("2022/03/18");
|
||||
});
|
||||
|
||||
it("should use the given colors", () => {
|
||||
const result = original
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 12,
|
||||
backgroundColor: "shouldNotBeUsed",
|
||||
foregroundColor: "shouldNotBeUsed",
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock)) as DummyIcon;
|
||||
|
||||
expect(result.features.viewPortIncreasePercent).toEqual(12);
|
||||
expect(HOLI_COLORS.includes(result.features.backgroundColor!)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(HOLI_COLORS.includes(result.features.foregroundColor!)).toEqual(
|
||||
true
|
||||
);
|
||||
expect(result.features.backgroundColor).not.toEqual(
|
||||
result.features.foregroundColor
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("eq", () => {
|
||||
it("should be true when ===", () => {
|
||||
expect(eq("Foo")("foo")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should be false when not ===", () => {
|
||||
expect(eq("Foo")("bar")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contains", () => {
|
||||
it("should be true word is a substring", () => {
|
||||
expect(contains("Foo")("some foo bar")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should be false when not ===", () => {
|
||||
expect(contains("Foo")("some bar")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("containsWord", () => {
|
||||
it("should be true word is a substring with space delim", () => {
|
||||
expect(containsWord("Foo")("some foo bar")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should be true word is a substring with hyphen delim", () => {
|
||||
expect(containsWord("Foo")("some----foo-bar")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should be false when not ===", () => {
|
||||
expect(containsWord("Foo")("somefoobar")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconForGenre", () => {
|
||||
[
|
||||
["Acid House", "mushroom"],
|
||||
["African", "african"],
|
||||
["Alternative Rock", "rock"],
|
||||
["Americana", "americana"],
|
||||
["Anti-Folk", "guitar"],
|
||||
["Audio-Book", "book"],
|
||||
["Australian Hip Hop", "oz"],
|
||||
["Rap", "rap"],
|
||||
["Hip Hop", "hipHop"],
|
||||
["Hip-Hop", "hipHop"],
|
||||
["Metal", "metal"],
|
||||
["Horrorcore", "horror"],
|
||||
["Punk", "punk"],
|
||||
["blah", "music"],
|
||||
].forEach(([genre, expected]) => {
|
||||
describe(`a genre of ${genre}`, () => {
|
||||
it(`should have an icon of ${expected}`, () => {
|
||||
const name = iconForGenre(genre!)!;
|
||||
expect(name).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`a genre of ${genre!.toLowerCase()}`, () => {
|
||||
it(`should have an icon of ${expected}`, () => {
|
||||
const name = iconForGenre(genre!)!;
|
||||
expect(name).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
HIP_HOP,
|
||||
SKA,
|
||||
} from "./builders";
|
||||
import _ from "underscore";
|
||||
|
||||
describe("InMemoryMusicService", () => {
|
||||
const service = new InMemoryMusicService();
|
||||
@@ -210,6 +211,7 @@ describe("InMemoryMusicService", () => {
|
||||
const artist3_album2 = anAlbum({ genre: POP });
|
||||
|
||||
const artist1 = anArtist({
|
||||
name: "artist1",
|
||||
albums: [
|
||||
artist1_album1,
|
||||
artist1_album2,
|
||||
@@ -218,8 +220,8 @@ describe("InMemoryMusicService", () => {
|
||||
artist1_album5,
|
||||
],
|
||||
});
|
||||
const artist2 = anArtist({ albums: [artist2_album1] });
|
||||
const artist3 = anArtist({ albums: [artist3_album1, artist3_album2] });
|
||||
const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] });
|
||||
const artist3 = anArtist({ name: "artist3", albums: [artist3_album1, artist3_album2] });
|
||||
const artistWithNoAlbums = anArtist({ albums: [] });
|
||||
|
||||
const allAlbums = [artist1, artist2, artist3, artistWithNoAlbums].flatMap(
|
||||
@@ -265,29 +267,48 @@ describe("InMemoryMusicService", () => {
|
||||
describe("fetching multiple albums", () => {
|
||||
describe("with no filtering", () => {
|
||||
describe("fetching all on one page", () => {
|
||||
it("should return all the albums for all the artists", async () => {
|
||||
expect(
|
||||
await musicLibrary.albums({
|
||||
_index: 0,
|
||||
_count: 100,
|
||||
type: "alphabeticalByArtist",
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
describe("alphabeticalByArtist", () => {
|
||||
it("should return all the albums for all the artists", async () => {
|
||||
expect(
|
||||
await musicLibrary.albums({
|
||||
_index: 0,
|
||||
_count: 100,
|
||||
type: "alphabeticalByArtist",
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("alphabeticalByName", () => {
|
||||
it("should return all the albums for all the artists", async () => {
|
||||
expect(
|
||||
await musicLibrary.albums({
|
||||
_index: 0,
|
||||
_count: 100,
|
||||
type: "alphabeticalByName",
|
||||
})
|
||||
).toEqual({
|
||||
results:
|
||||
_.sortBy(allAlbums, 'name').map(albumToAlbumSummary),
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("fetching a page", () => {
|
||||
|
||||
@@ -77,7 +77,9 @@ export class InMemoryMusicService implements MusicService {
|
||||
switch (q.type) {
|
||||
case "alphabeticalByArtist":
|
||||
return artist2Album;
|
||||
case "byGenre":
|
||||
case "alphabeticalByName":
|
||||
return artist2Album.sort((a, b) => a.album.name.localeCompare(b.album.name));
|
||||
case "byGenre":
|
||||
return artist2Album.filter(
|
||||
(it) => it.album.genre?.id === q.genre
|
||||
);
|
||||
|
||||
116
tests/registrar.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import axios from "axios";
|
||||
jest.mock("axios");
|
||||
|
||||
const fakeSonos = {
|
||||
register: jest.fn(),
|
||||
};
|
||||
|
||||
import sonos, { bonobService } from "../src/sonos";
|
||||
jest.mock("../src/sonos");
|
||||
|
||||
import registrar from "../src/registrar";
|
||||
import { URLBuilder } from "../src/url_builder";
|
||||
|
||||
describe("registrar", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("when the bonob service can not be found", () => {
|
||||
it("should fail", async () => {
|
||||
const status = 409;
|
||||
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
status,
|
||||
});
|
||||
|
||||
const bonobUrl = new URLBuilder("http://fail.example.com/bonob");
|
||||
|
||||
return expect(registrar(bonobUrl)()).rejects.toEqual(
|
||||
`Unexpected response status ${status} from ${bonobUrl
|
||||
.append({ pathname: "/about" })
|
||||
.href()}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the bonob service returns unexpected content", () => {
|
||||
it("should fail", async () => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
status: 200,
|
||||
// invalid response from /about as does not have name and sid
|
||||
data: {}
|
||||
});
|
||||
|
||||
const bonobUrl = new URLBuilder("http://fail.example.com/bonob");
|
||||
|
||||
return expect(registrar(bonobUrl)()).rejects.toEqual(
|
||||
`Unexpected response from ${bonobUrl
|
||||
.append({ pathname: "/about" })
|
||||
.href()}, expected service.name and service.sid`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the bonob service can be found", () => {
|
||||
const bonobUrl = new URLBuilder("http://success.example.com/bonob");
|
||||
|
||||
const serviceDetails = {
|
||||
name: "bob",
|
||||
sid: 123,
|
||||
};
|
||||
|
||||
const service = "service";
|
||||
|
||||
beforeEach(() => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
service: serviceDetails,
|
||||
},
|
||||
});
|
||||
|
||||
(bonobService as jest.Mock).mockResolvedValue(service);
|
||||
(sonos as jest.Mock).mockReturnValue(fakeSonos);
|
||||
});
|
||||
|
||||
describe("when registration succeeds", () => {
|
||||
it("should fetch the service details and register", async () => {
|
||||
fakeSonos.register.mockResolvedValue(true);
|
||||
const sonosDiscovery = { auto: true };
|
||||
|
||||
expect(await registrar(bonobUrl, sonosDiscovery)()).toEqual(
|
||||
true
|
||||
);
|
||||
|
||||
expect(bonobService).toHaveBeenCalledWith(
|
||||
serviceDetails.name,
|
||||
serviceDetails.sid,
|
||||
bonobUrl
|
||||
);
|
||||
expect(sonos).toHaveBeenCalledWith(sonosDiscovery);
|
||||
expect(fakeSonos.register).toHaveBeenCalledWith(service);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when registration fails", () => {
|
||||
it("should fetch the service details and register", async () => {
|
||||
fakeSonos.register.mockResolvedValue(false);
|
||||
const sonosDiscovery = { auto: false, seedHost: "192.168.1.163" };
|
||||
|
||||
expect(await registrar(bonobUrl, sonosDiscovery)()).toEqual(
|
||||
false
|
||||
);
|
||||
|
||||
expect(bonobService).toHaveBeenCalledWith(
|
||||
serviceDetails.name,
|
||||
serviceDetails.sid,
|
||||
bonobUrl
|
||||
);
|
||||
expect(sonos).toHaveBeenCalledWith(sonosDiscovery);
|
||||
expect(fakeSonos.register).toHaveBeenCalledWith(service);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GetMetadataResponse,
|
||||
} from "../src/smapi";
|
||||
import {
|
||||
aDevice,
|
||||
BLONDIE,
|
||||
BOB_MARLEY,
|
||||
getAppLinkMessage,
|
||||
@@ -19,7 +20,7 @@ import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||
import { Credentials } from "../src/music_service";
|
||||
import makeServer from "../src/server";
|
||||
import { Service, bonobService, SONOS_DISABLED } from "../src/sonos";
|
||||
import { Service, bonobService, Sonos } from "../src/sonos";
|
||||
import supersoap from "./supersoap";
|
||||
import url, { URLBuilder } from "../src/url_builder";
|
||||
|
||||
@@ -138,8 +139,6 @@ class SonosDriver {
|
||||
return m![1]!;
|
||||
});
|
||||
|
||||
console.log(`posting to action ${action}`);
|
||||
|
||||
return request(this.server)
|
||||
.post(action)
|
||||
.type("form")
|
||||
@@ -173,6 +172,17 @@ describe("scenarios", () => {
|
||||
);
|
||||
const linkCodes = new InMemoryLinkCodes();
|
||||
|
||||
const fakeSonos: Sonos = {
|
||||
devices: () => Promise.resolve([aDevice({
|
||||
name: "device1",
|
||||
ip: "172.0.0.1",
|
||||
port: 4301,
|
||||
})]),
|
||||
services: () => Promise.resolve([]),
|
||||
remove: () => Promise.resolve(true),
|
||||
register: () => Promise.resolve(true),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
musicService.clear();
|
||||
linkCodes.clear();
|
||||
@@ -245,7 +255,7 @@ describe("scenarios", () => {
|
||||
...BLONDIE.albums,
|
||||
...BOB_MARLEY.albums,
|
||||
...MADONNA.albums,
|
||||
].map((it) => it.name)
|
||||
].map((it) => it.name).sort()
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -257,11 +267,13 @@ describe("scenarios", () => {
|
||||
const bonobUrl = url("http://localhost:1234");
|
||||
const bonob = bonobService("bonob", 123, bonobUrl);
|
||||
const server = makeServer(
|
||||
SONOS_DISABLED,
|
||||
fakeSonos,
|
||||
bonob,
|
||||
bonobUrl,
|
||||
musicService,
|
||||
linkCodes
|
||||
{
|
||||
linkCodes: () => linkCodes
|
||||
}
|
||||
);
|
||||
|
||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||
@@ -273,11 +285,13 @@ describe("scenarios", () => {
|
||||
const bonobUrl = url("http://localhost:1234/");
|
||||
const bonob = bonobService("bonob", 123, bonobUrl);
|
||||
const server = makeServer(
|
||||
SONOS_DISABLED,
|
||||
fakeSonos,
|
||||
bonob,
|
||||
bonobUrl,
|
||||
musicService,
|
||||
linkCodes
|
||||
{
|
||||
linkCodes: () => linkCodes
|
||||
}
|
||||
);
|
||||
|
||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||
@@ -289,11 +303,13 @@ describe("scenarios", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-for-bonob");
|
||||
const bonob = bonobService("bonob", 123, bonobUrl);
|
||||
const server = makeServer(
|
||||
SONOS_DISABLED,
|
||||
fakeSonos,
|
||||
bonob,
|
||||
bonobUrl,
|
||||
musicService,
|
||||
linkCodes
|
||||
{
|
||||
linkCodes: () => linkCodes
|
||||
}
|
||||
);
|
||||
|
||||
const sonosDriver = new SonosDriver(server, bonobUrl, bonob);
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
defaultAlbumArtURI,
|
||||
defaultArtistArtURI,
|
||||
searchResult,
|
||||
iconArtURI,
|
||||
playlistAlbumArtURL,
|
||||
} from "../src/smapi";
|
||||
|
||||
import {
|
||||
@@ -42,10 +44,12 @@ import {
|
||||
albumToAlbumSummary,
|
||||
artistToArtistSummary,
|
||||
MusicService,
|
||||
playlistToPlaylistSummary,
|
||||
} from "../src/music_service";
|
||||
import { AccessTokens } from "../src/access_tokens";
|
||||
import dayjs from "dayjs";
|
||||
import url from "../src/url_builder";
|
||||
import { iconForGenre } from "../src/icon";
|
||||
|
||||
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
||||
|
||||
@@ -54,82 +58,107 @@ describe("service config", () => {
|
||||
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
|
||||
|
||||
[bonobWithNoContextPath, bonobWithContextPath].forEach((bonobUrl) => {
|
||||
const server = makeServer(
|
||||
SONOS_DISABLED,
|
||||
aService({ name: "music land" }),
|
||||
bonobUrl,
|
||||
new InMemoryMusicService()
|
||||
);
|
||||
describe(bonobUrl.href(), () => {
|
||||
const server = makeServer(
|
||||
SONOS_DISABLED,
|
||||
aService({ name: "music land" }),
|
||||
bonobUrl,
|
||||
new InMemoryMusicService()
|
||||
);
|
||||
|
||||
const stringsUrl = bonobUrl.append({ pathname: STRINGS_ROUTE });
|
||||
const presentationUrl = bonobUrl.append({
|
||||
pathname: PRESENTATION_MAP_ROUTE,
|
||||
});
|
||||
|
||||
describe(`${stringsUrl}`, () => {
|
||||
async function fetchStringsXml() {
|
||||
const res = await request(server).get(stringsUrl.path()).send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
||||
return parseXML(
|
||||
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
|
||||
);
|
||||
}
|
||||
|
||||
it("should return xml for the strings", async () => {
|
||||
const xml = await fetchStringsXml();
|
||||
|
||||
const sonosString = (id: string, lang: string) =>
|
||||
xpath.select(
|
||||
`string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="${id}"])`,
|
||||
xml
|
||||
);
|
||||
|
||||
expect(sonosString("AppLinkMessage", "en-US")).toEqual(
|
||||
"Linking sonos with music land"
|
||||
);
|
||||
expect(sonosString("AppLinkMessage", "nl-NL")).toEqual(
|
||||
"Sonos koppelen aan music land"
|
||||
);
|
||||
|
||||
// no fr-FR translation, so use en-US
|
||||
expect(sonosString("AppLinkMessage", "fr-FR")).toEqual(
|
||||
"Linking sonos with music land"
|
||||
);
|
||||
const stringsUrl = bonobUrl.append({ pathname: STRINGS_ROUTE });
|
||||
const presentationUrl = bonobUrl.append({
|
||||
pathname: PRESENTATION_MAP_ROUTE,
|
||||
});
|
||||
|
||||
it("should return a section for all sonos supported languages", async () => {
|
||||
const xml = await fetchStringsXml();
|
||||
SONOS_LANG.forEach(lang => {
|
||||
expect(xpath.select(
|
||||
`string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="AppLinkMessage"])`,
|
||||
xml
|
||||
)).toBeDefined();
|
||||
describe(STRINGS_ROUTE, () => {
|
||||
async function fetchStringsXml() {
|
||||
const res = await request(server).get(stringsUrl.path()).send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
||||
return parseXML(
|
||||
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
|
||||
);
|
||||
}
|
||||
|
||||
it("should return xml for the strings", async () => {
|
||||
const xml = await fetchStringsXml();
|
||||
|
||||
const sonosString = (id: string, lang: string) =>
|
||||
xpath.select(
|
||||
`string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="${id}"])`,
|
||||
xml
|
||||
);
|
||||
|
||||
expect(sonosString("AppLinkMessage", "en-US")).toEqual(
|
||||
"Linking sonos with music land"
|
||||
);
|
||||
expect(sonosString("AppLinkMessage", "nl-NL")).toEqual(
|
||||
"Sonos koppelen aan music land"
|
||||
);
|
||||
|
||||
// no fr-FR translation, so use en-US
|
||||
expect(sonosString("AppLinkMessage", "fr-FR")).toEqual(
|
||||
"Linking sonos with music land"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return a section for all sonos supported languages", async () => {
|
||||
const xml = await fetchStringsXml();
|
||||
SONOS_LANG.forEach((lang) => {
|
||||
expect(
|
||||
xpath.select(
|
||||
`string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="AppLinkMessage"])`,
|
||||
xml
|
||||
)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`${presentationUrl}`, () => {
|
||||
it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => {
|
||||
const res = await request(server).get(presentationUrl.path()).send();
|
||||
describe(PRESENTATION_MAP_ROUTE, () => {
|
||||
it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => {
|
||||
const res = await request(server).get(presentationUrl.path()).send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
||||
const xml = parseXML(
|
||||
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
|
||||
);
|
||||
|
||||
const imageSizeMap = (size: string) =>
|
||||
xpath.select(
|
||||
`string(/Presentation/PresentationMap[@type="ArtWorkSizeMap"]/Match/imageSizeMap/sizeEntry[@size="${size}"]/@substitution)`,
|
||||
xml
|
||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
||||
const xml = parseXML(
|
||||
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
|
||||
);
|
||||
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => {
|
||||
expect(imageSizeMap(size)).toEqual(`/art/size/${size}`);
|
||||
const imageSizeMap = (size: string) =>
|
||||
xpath.select(
|
||||
`string(/Presentation/PresentationMap[@type="ArtWorkSizeMap"]/Match/imageSizeMap/sizeEntry[@size="${size}"]/@substitution)`,
|
||||
xml
|
||||
);
|
||||
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => {
|
||||
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have an BrowseIconSizeMap for all sizes recommended by sonos", async () => {
|
||||
const res = await request(server).get(presentationUrl.path()).send();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
||||
const xml = parseXML(
|
||||
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
|
||||
);
|
||||
|
||||
const imageSizeMap = (size: string) =>
|
||||
xpath.select(
|
||||
`string(/Presentation/PresentationMap[@type="BrowseIconSizeMap"]/Match/browseIconSizeMap/sizeEntry[@size="${size}"]/@substitution)`,
|
||||
xml
|
||||
);
|
||||
|
||||
SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => {
|
||||
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -246,7 +275,7 @@ describe("track", () => {
|
||||
albumId: someTrack.album.id,
|
||||
albumArtist: someTrack.artist.name,
|
||||
albumArtistId: someTrack.artist.id,
|
||||
albumArtURI: `http://localhost:4567/foo/album/${someTrack.album.id}/art/size/180?access-token=1234`,
|
||||
albumArtURI: `http://localhost:4567/foo/art/album/${someTrack.album.id}/size/180?access-token=1234`,
|
||||
artist: someTrack.artist.name,
|
||||
artistId: someTrack.artist.id,
|
||||
duration: someTrack.duration,
|
||||
@@ -270,7 +299,82 @@ describe("album", () => {
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, someAlbum).href(),
|
||||
canPlay: true,
|
||||
artist: someAlbum.artistName,
|
||||
artistId: someAlbum.artistId,
|
||||
artistId: `artist:${someAlbum.artistId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlistAlbumArtURL", () => {
|
||||
describe("when the playlist has no albumIds", () => {
|
||||
it("should return question mark icon", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [aTrack({ album: undefined }), aTrack({ album: undefined })],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/icon/error/size/legacy?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the playlist has 2 distinct albumIds", () => {
|
||||
it("should return them on the url to the image", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the playlist has 4 distinct albumIds", () => {
|
||||
it("should return them on the url to the image", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2&3&4/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the playlist has 9 distinct albumIds", () => {
|
||||
it("should return 9 of the ids on the url", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "5" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "6" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "7" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "8" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "9" })) }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2&3&4&5&6&7&8&9/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -281,7 +385,7 @@ describe("defaultAlbumArtURI", () => {
|
||||
const album = anAlbum();
|
||||
|
||||
expect(defaultAlbumArtURI(bonobUrl, album).href()).toEqual(
|
||||
`http://localhost:1234/context-path/album/${album.id}/art/size/180?search=yes`
|
||||
`http://localhost:1234/context-path/art/album/${album.id}/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -292,7 +396,7 @@ describe("defaultArtistArtURI", () => {
|
||||
const artist = anArtist();
|
||||
|
||||
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
||||
`http://localhost:1234/something/artist/${artist.id}/art/size/180?s=123`
|
||||
`http://localhost:1234/something/art/artist/${artist.id}/size/180?s=123`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -344,7 +448,7 @@ describe("api", () => {
|
||||
const accessToken = `accessToken-${uuid()}`;
|
||||
|
||||
const bonobUrlWithAccessToken = bonobUrl.append({
|
||||
searchParams: { "bonob-access-token": accessToken },
|
||||
searchParams: { "bat": accessToken },
|
||||
});
|
||||
|
||||
const service = bonobService("test-api", 133, bonobUrl, "AppLink");
|
||||
@@ -353,9 +457,11 @@ describe("api", () => {
|
||||
service,
|
||||
bonobUrl,
|
||||
musicService as unknown as MusicService,
|
||||
linkCodes as unknown as LinkCodes,
|
||||
accessTokens as unknown as AccessTokens,
|
||||
clock
|
||||
{
|
||||
linkCodes: () => linkCodes as unknown as LinkCodes,
|
||||
accessTokens: () => accessTokens as unknown as AccessTokens,
|
||||
clock,
|
||||
}
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -450,7 +556,8 @@ describe("api", () => {
|
||||
.catch((e: any) => {
|
||||
expect(e.root.Envelope.Body.Fault).toEqual({
|
||||
faultcode: "Client.NOT_LINKED_RETRY",
|
||||
faultstring: "Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
faultstring:
|
||||
"Link Code not found yet, sonos app will keep polling until you log in to bonob",
|
||||
detail: {
|
||||
ExceptionInfo: "NOT_LINKED_RETRY",
|
||||
SonosError: "5",
|
||||
@@ -707,46 +814,72 @@ describe("api", () => {
|
||||
getMetadataResult({
|
||||
mediaCollection: [
|
||||
{
|
||||
itemType: "container",
|
||||
id: "artists",
|
||||
title: "Artists",
|
||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "albums",
|
||||
title: "Albums",
|
||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{ itemType: "albumList", id: "albums", title: "Albums" },
|
||||
{
|
||||
itemType: "playlist",
|
||||
id: "playlists",
|
||||
title: "Playlists",
|
||||
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
|
||||
itemType: "playlist",
|
||||
attributes: {
|
||||
readOnly: "false",
|
||||
renameable: "false",
|
||||
userContent: "true",
|
||||
},
|
||||
},
|
||||
{ itemType: "container", id: "genres", title: "Genres" },
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "genres",
|
||||
title: "Genres",
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "randomAlbums",
|
||||
title: "Random",
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "starredAlbums",
|
||||
title: "Starred",
|
||||
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyAdded",
|
||||
title: "Recently added",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyAdded"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyPlayed",
|
||||
title: "Recently played",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "mostPlayed",
|
||||
title: "Most played",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"mostPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
@@ -758,7 +891,7 @@ describe("api", () => {
|
||||
|
||||
describe("when an accept-language header is present with value nl-NL", () => {
|
||||
it("should return nl-NL", async () => {
|
||||
ws.addHttpHeader("accept-language", "nl-NL")
|
||||
ws.addHttpHeader("accept-language", "nl-NL, en-US;q=0.9");
|
||||
const root = await ws.getMetadataAsync({
|
||||
id: "root",
|
||||
index: 0,
|
||||
@@ -768,46 +901,72 @@ describe("api", () => {
|
||||
getMetadataResult({
|
||||
mediaCollection: [
|
||||
{
|
||||
itemType: "container",
|
||||
id: "artists",
|
||||
title: "Artiesten",
|
||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "albums",
|
||||
title: "Albums",
|
||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{ itemType: "albumList", id: "albums", title: "Albums" },
|
||||
{
|
||||
itemType: "playlist",
|
||||
id: "playlists",
|
||||
title: "Afspeellijsten",
|
||||
albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
|
||||
itemType: "playlist",
|
||||
attributes: {
|
||||
readOnly: "false",
|
||||
renameable: "false",
|
||||
userContent: "true",
|
||||
},
|
||||
},
|
||||
{ itemType: "container", id: "genres", title: "Genres" },
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "genres",
|
||||
title: "Genres",
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "randomAlbums",
|
||||
title: "Willekeurig",
|
||||
albumArtURI: iconArtURI(bonobUrl, "random").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "starredAlbums",
|
||||
title: "Favorieten",
|
||||
albumArtURI: iconArtURI(bonobUrl, "starred").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyAdded",
|
||||
title: "Onlangs toegevoegd",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyAdded"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyPlayed",
|
||||
title: "Onlangs afgespeeld",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"recentlyPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "mostPlayed",
|
||||
title: "Meest afgespeeld",
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"mostPlayed"
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
@@ -859,6 +1018,10 @@ describe("api", () => {
|
||||
itemType: "container",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name),
|
||||
).href(),
|
||||
})),
|
||||
index: 0,
|
||||
total: expectedGenres.length,
|
||||
@@ -880,6 +1043,10 @@ describe("api", () => {
|
||||
itemType: "container",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name),
|
||||
).href(),
|
||||
})),
|
||||
index: 1,
|
||||
total: expectedGenres.length,
|
||||
@@ -890,30 +1057,40 @@ describe("api", () => {
|
||||
});
|
||||
|
||||
describe("asking for playlists", () => {
|
||||
const expectedPlayLists = [
|
||||
{ id: "1", name: "pl1" },
|
||||
{ id: "2", name: "pl2" },
|
||||
{ id: "3", name: "pl3" },
|
||||
{ id: "4", name: "pl4" },
|
||||
];
|
||||
const playlist1 = aPlaylist({ id: "1", name: "pl1" });
|
||||
const playlist2 = aPlaylist({ id: "2", name: "pl2" });
|
||||
const playlist3 = aPlaylist({ id: "3", name: "pl3" });
|
||||
const playlist4 = aPlaylist({ id: "4", name: "pl4" });
|
||||
|
||||
const playlists = [playlist1, playlist2, playlist3, playlist4];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.playlists.mockResolvedValue(expectedPlayLists);
|
||||
musicLibrary.playlists.mockResolvedValue(
|
||||
playlists.map(playlistToPlaylistSummary)
|
||||
);
|
||||
musicLibrary.playlist.mockResolvedValueOnce(playlist1);
|
||||
musicLibrary.playlist.mockResolvedValueOnce(playlist2);
|
||||
musicLibrary.playlist.mockResolvedValueOnce(playlist3);
|
||||
musicLibrary.playlist.mockResolvedValueOnce(playlist4);
|
||||
});
|
||||
|
||||
describe("asking for all playlists", () => {
|
||||
it("should return a collection of playlists", async () => {
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: `playlists`,
|
||||
id: "playlists",
|
||||
index: 0,
|
||||
count: 100,
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: expectedPlayLists.map((playlist) => ({
|
||||
mediaCollection: playlists.map((playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
title: playlist.name,
|
||||
albumArtURI: playlistAlbumArtURL(
|
||||
bonobUrlWithAccessToken,
|
||||
playlist
|
||||
).href(),
|
||||
canPlay: true,
|
||||
attributes: {
|
||||
readOnly: "false",
|
||||
@@ -922,7 +1099,7 @@ describe("api", () => {
|
||||
},
|
||||
})),
|
||||
index: 0,
|
||||
total: expectedPlayLists.length,
|
||||
total: playlists.length,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -937,22 +1114,25 @@ describe("api", () => {
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [
|
||||
expectedPlayLists[1]!,
|
||||
expectedPlayLists[2]!,
|
||||
].map((playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
title: playlist.name,
|
||||
canPlay: true,
|
||||
attributes: {
|
||||
readOnly: "false",
|
||||
userContent: "false",
|
||||
renameable: "false",
|
||||
},
|
||||
})),
|
||||
mediaCollection: [playlists[1]!, playlists[2]!].map(
|
||||
(playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
title: playlist.name,
|
||||
albumArtURI: playlistAlbumArtURL(
|
||||
bonobUrlWithAccessToken,
|
||||
playlist
|
||||
).href(),
|
||||
canPlay: true,
|
||||
attributes: {
|
||||
readOnly: "false",
|
||||
userContent: "false",
|
||||
renameable: "false",
|
||||
},
|
||||
})
|
||||
),
|
||||
index: 1,
|
||||
total: expectedPlayLists.length,
|
||||
total: playlists.length,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -988,7 +1168,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})
|
||||
),
|
||||
@@ -1025,7 +1205,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 2,
|
||||
@@ -1329,7 +1509,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1377,7 +1557,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1425,7 +1605,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1473,7 +1653,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1521,7 +1701,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1567,7 +1747,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1576,7 +1756,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "alphabeticalByArtist",
|
||||
type: "alphabeticalByName",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
@@ -1613,7 +1793,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 2,
|
||||
@@ -1622,7 +1802,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "alphabeticalByArtist",
|
||||
type: "alphabeticalByName",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
@@ -1657,7 +1837,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1704,7 +1884,7 @@ describe("api", () => {
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: it.artistId,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
@@ -2164,7 +2344,7 @@ describe("api", () => {
|
||||
album
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: album.artistId,
|
||||
artistId: `artist:${album.artistId}`,
|
||||
artist: album.artistName,
|
||||
},
|
||||
},
|
||||
@@ -2248,7 +2428,7 @@ describe("api", () => {
|
||||
})
|
||||
.href(),
|
||||
httpHeaders: {
|
||||
header: "bonob-access-token",
|
||||
header: "bat",
|
||||
value: accessToken,
|
||||
},
|
||||
});
|
||||
@@ -2330,7 +2510,7 @@ describe("api", () => {
|
||||
expect(root[0]).toEqual({
|
||||
getMediaMetadataResult: track(
|
||||
bonobUrl.with({
|
||||
searchParams: { "bonob-access-token": accessToken },
|
||||
searchParams: { "bat": accessToken },
|
||||
}),
|
||||
someTrack
|
||||
),
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("sonos", () => {
|
||||
|
||||
describe("when is disabled", () => {
|
||||
it("should return a disabled client", async () => {
|
||||
const disabled = sonos(false);
|
||||
const disabled = sonos({ auto: false });
|
||||
|
||||
expect(disabled).toEqual(SONOS_DISABLED);
|
||||
expect(await disabled.devices()).toEqual([]);
|
||||
@@ -310,7 +310,7 @@ describe("sonos", () => {
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const actualDevices = await sonos(true, undefined).devices();
|
||||
const actualDevices = await sonos({ auto: true }).devices();
|
||||
|
||||
expect(SonosManager).toHaveBeenCalledTimes(1);
|
||||
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
|
||||
@@ -331,7 +331,7 @@ describe("sonos", () => {
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const actualDevices = await sonos(true, "").devices();
|
||||
const actualDevices = await sonos({ auto: true, seedHost: "" }).devices();
|
||||
|
||||
expect(SonosManager).toHaveBeenCalledTimes(1);
|
||||
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
|
||||
@@ -354,7 +354,7 @@ describe("sonos", () => {
|
||||
);
|
||||
sonosManager.InitializeFromDevice.mockResolvedValue(true);
|
||||
|
||||
const actualDevices = await sonos(true, seedHost).devices();
|
||||
const actualDevices = await sonos({ auto: true, seedHost }).devices();
|
||||
|
||||
expect(SonosManager).toHaveBeenCalledTimes(1);
|
||||
expect(sonosManager.InitializeFromDevice).toHaveBeenCalledWith(
|
||||
@@ -377,7 +377,7 @@ describe("sonos", () => {
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
|
||||
|
||||
const actualDevices = await sonos(true, undefined).devices();
|
||||
const actualDevices = await sonos({ auto: true, seedHost: undefined }).devices();
|
||||
|
||||
expect(actualDevices).toEqual([
|
||||
{
|
||||
@@ -408,7 +408,7 @@ describe("sonos", () => {
|
||||
);
|
||||
sonosManager.InitializeWithDiscovery.mockResolvedValue(false);
|
||||
|
||||
expect(await sonos(true, "").devices()).toEqual([]);
|
||||
expect(await sonos({ auto: true, seedHost: "" }).devices()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Express } from "express";
|
||||
import { ReadStream } from "fs";
|
||||
import { IHttpClient } from "soap";
|
||||
import request from "supertest";
|
||||
import * as req from "axios";
|
||||
|
||||
function supersoap(server: Express) {
|
||||
function supersoap(server: Express): IHttpClient {
|
||||
return {
|
||||
request: (
|
||||
rurl: string,
|
||||
@@ -15,12 +18,19 @@ function supersoap(server: Express) {
|
||||
data == null
|
||||
? request(server).get(withoutHost).send()
|
||||
: request(server).post(withoutHost).send(data);
|
||||
req
|
||||
return req
|
||||
.set(exheaders || {})
|
||||
.then((response) => callback(null, response, response.text))
|
||||
.catch(callback);
|
||||
},
|
||||
}
|
||||
|
||||
requestStream: (
|
||||
_: string,
|
||||
_2: any
|
||||
): req.AxiosPromise<ReadStream> => {
|
||||
throw "Not Implemented!!";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default supersoap
|
||||
export default supersoap;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"typeRoots" : [
|
||||
"../typings",
|
||||
"../node_modules/@types"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -138,15 +138,19 @@ describe("URLBuilder", () => {
|
||||
describe("with URLSearchParams", () => {
|
||||
it("should return a new URLBuilder with the new search params appended", () => {
|
||||
const original = url("https://example.com/some-path?a=b&c=d");
|
||||
const searchParams = new URLSearchParams({ x: "y" });
|
||||
searchParams.append("z", "1");
|
||||
searchParams.append("z", "2");
|
||||
|
||||
const updated = original.append({
|
||||
searchParams: new URLSearchParams({ x: "y", z: "1" }),
|
||||
searchParams,
|
||||
});
|
||||
|
||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1");
|
||||
expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1")
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1&z=2");
|
||||
expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1&z=2")
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -168,15 +172,19 @@ describe("URLBuilder", () => {
|
||||
|
||||
it("should return a new URLBuilder with the new search params", () => {
|
||||
const original = url("https://example.com/some-path?a=b&c=d");
|
||||
const searchParams = new URLSearchParams({ x: "y" });
|
||||
searchParams.append("z", "1");
|
||||
searchParams.append("z", "2");
|
||||
|
||||
const updated = original.with({
|
||||
searchParams: { x: "y", z: "1" },
|
||||
searchParams,
|
||||
});
|
||||
|
||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1");
|
||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1")
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1&z=2");
|
||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1&z=2")
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,15 +204,19 @@ describe("URLBuilder", () => {
|
||||
|
||||
it("should return a new URLBuilder with the new search params", () => {
|
||||
const original = url("https://example.com/some-path?a=b&c=d");
|
||||
const searchParams = new URLSearchParams({ x: "y" });
|
||||
searchParams.append("z", "1");
|
||||
searchParams.append("z", "2");
|
||||
|
||||
const updated = original.with({
|
||||
searchParams: new URLSearchParams({ x: "y", z: "1" }),
|
||||
searchParams,
|
||||
});
|
||||
|
||||
expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d");
|
||||
expect(`${original.searchParams()}`).toEqual("a=b&c=d")
|
||||
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1");
|
||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1")
|
||||
expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1&z=2");
|
||||
expect(`${updated.searchParams()}`).toEqual("x=y&z=1&z=2")
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
35
tests/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { takeWithRepeats } from "../src/utils";
|
||||
|
||||
describe("takeWithRepeat", () => {
|
||||
describe("when there is nothing in the input", () => {
|
||||
it("should return an array of undefineds", () => {
|
||||
expect(takeWithRepeats([], 3)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there are exactly the amount required", () => {
|
||||
it("should return them all", () => {
|
||||
expect(takeWithRepeats(["a", undefined, "c"], 3)).toEqual([
|
||||
"a",
|
||||
undefined,
|
||||
"c",
|
||||
]);
|
||||
expect(takeWithRepeats(["a"], 1)).toEqual(["a"]);
|
||||
expect(takeWithRepeats([undefined], 1)).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there are less than the amount required", () => {
|
||||
it("should cycle through the ones available", () => {
|
||||
expect(takeWithRepeats(["a", "b"], 3)).toEqual(["a", "b", "a"]);
|
||||
expect(takeWithRepeats(["a", "b"], 5)).toEqual(["a", "b", "a", "b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there more than the amount required", () => {
|
||||
it("should return the first n items", () => {
|
||||
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
"lib": ["es2019"], /* Specify library files to be included in the compilation. */
|
||||
"target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||
"lib": [
|
||||
"es2019"
|
||||
] /* Specify library files to be included in the compilation. */,
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
@@ -14,8 +16,8 @@
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./build", /* Redirect output structure to the directory. */
|
||||
"rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
"outDir": "./build" /* Redirect output structure to the directory. */,
|
||||
"rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
@@ -25,31 +27,35 @@
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */,
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
"noUnusedLocals": true /* Report errors on unused locals. */,
|
||||
"noUnusedParameters": true /* Report errors on unused parameters. */,
|
||||
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
|
||||
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||
"noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */,
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"typeRoots": [
|
||||
"./typings",
|
||||
"node_modules/@types"
|
||||
]
|
||||
/* List of folders to include type definitions from. */,
|
||||
// "types": ["src/customTypes/scale-that-svg.d.ts"], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
@@ -64,7 +70,7 @@
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
}
|
||||
}
|
||||
|
||||
4
typings/scale-that-svg/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "scale-that-svg" {
|
||||
const noTypesYet: any;
|
||||
export default noTypesYet;
|
||||
}
|
||||
3
web/icons/Africa-48087.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M4.96 2.126l.613.416c.301.204.663.321.982.425.158.051.396.128.466.173l.035.023.01.006c.13.084.309.2.535.272C7.748 3.488 7.906 3.513 8.06 3.513c.339 0 .651-.12.881-.339.274.105.601.224.972.25.038.135.068.273.1.423.057.267.122.569.239.889.089.259.208.609.491.91.17.203.355.324.48.407l.031.021c.029.04.076.119.111.179.075.128.166.282.29.437.124.187.296.347.49.456-.088.076-.18.15-.278.222-.365.24-.64.498-.839.788L11.004 8.19l-.02.035-.027.047c-.217.377-.542.941-.423 1.627.034.285.108.529.174.745.017.055.034.11.05.166l-.022.011-.034.02c-.096.056-.938.57-.938 1.369 0 .095.01.182.027.262-.218.2-.351.476-.354.785-.104.14-.181.278-.247.397-.023.042-.046.085-.071.126-.071.037-.216.084-.395.12-.093-.214-.222-.361-.308-.457-.158-.228-.362-.396-.544-.545-.039-.032-.089-.073-.131-.109-.014-.118-.032-.235-.059-.34-.099-.385-.315-.667-.489-.894-.032-.041-.073-.095-.105-.14.02-.049.046-.108.068-.156.099-.221.234-.523.265-.894l.004-.043v-.043c0-.499-.127-.942-.395-1.371.057-.163.105-.375.093-.624C7.139 8.2 7.158 8.088 7.158 7.957c0-.008-.022-.643-.291-1.164C6.855 6.754 6.841 6.716 6.825 6.678 6.626 6.218 6.181 5.985 5.5 5.985c-.04 0-.09 0-.148 0-.586 0-1.988 0-2.326-.001C2.999 5.971 2.972 5.955 2.946 5.94L2.741 5.824C2.63 5.762 2.451 5.662 2.269 5.557c.006-.015.011-.03.016-.045.221-.641.094-1.106-.069-1.397.039-.033.081-.064.126-.094l.06-.034c.237-.13.73-.402.92-1.048.023-.09.036-.175.042-.252.069-.054.145-.12.219-.201.005 0 .01 0 .014 0 .419 0 .775-.15 1.034-.259C4.678 2.209 4.72 2.191 4.761 2.175 4.827 2.156 4.893 2.14 4.96 2.126M5.889 1C5.382 1.035 4.911 1.071 4.441 1.211c-.25.091-.553.26-.841.26-.046 0-.092-.004-.137-.014-.109-.035-.181-.105-.29-.105-.109 0-.217.035-.254.105-.036.071 0 .105 0 .176C2.883 1.913 2.448 1.984 2.376 2.23c-.036.141 0 .316-.036.457C2.268 2.932 2.014 3.038 1.833 3.144c-.326.211-.579.457-.76.808C1.036 4.022 1 4.093 1 4.162c0 .141.217.281.29.386C1.434 4.725 1.398 4.97 1.326 5.181c-.073.211-.217.386-.29.562C1 5.814 1 5.885 1 5.919c.036.035.073.07.109.106.326.246 1.145.686 1.326.792.157.091.341.18.505.182C3 7 5 7 5.5 7c0 0 .5 0 .375.133C6.02 7.238 6.142 7.817 6.142 7.957c0 .14-.073.246-.036.352.036.387-.349.597-.096.913.254.316.398.632.398 1.054-.036.422-.362.773-.362 1.194 0 .457.543.808.652 1.23.036.141.036.316.073.457.073.316.639.597.82.878.073.106.181.175.217.316 0 .071 0 .141 0 .211 0 .175.145.351.326.422C8.166 14.995 8.201 15 8.238 15c.09 0 .192-.025.294-.05.398-.035 1.123-.175 1.376-.527.158-.219.254-.492.434-.668.109-.105.217-.211.181-.351-.036-.071-.073-.106-.073-.141 0-.071.145-.106.217-.106.073 0 .145 0 .217 0 .181-.035.254-.246.217-.386-.036-.175-.217-.281-.29-.457-.036-.035-.036-.07-.036-.105 0-.141.254-.387.434-.492.217-.105.471-.211.579-.422.073-.175.073-.351 0-.527-.073-.352-.217-.668-.254-1.019-.073-.351.145-.703.326-1.019.145-.211.362-.387.579-.527C13.167 7.677 13.891 6.878 14 6c-.254.211-.688.272-1.05.306-.109 0-.217 0-.29-.035-.073-.035-.145-.106-.181-.175C12.262 5.85 12.153 5.498 11.9 5.288c-.145-.106-.29-.175-.398-.317-.145-.141-.217-.352-.29-.563-.217-.598-.217-1.09-.471-1.687-.073-.14-.145-.316-.29-.316-.073 0-.145 0-.254 0-.045.007-.091.009-.136.009-.456 0-.912-.296-1.373-.391C8.645 2.009 8.594 2 8.544 2c-.07 0-.138.017-.18.058C8.256 2.164 8.348 2.335 8.24 2.44 8.197 2.481 8.13 2.498 8.06 2.498c-.049 0-.101-.008-.146-.023C7.805 2.44 7.701 2.369 7.592 2.299 7.23 2.053 6.506 1.948 6.144 1.702c.073-.105.109-.211.145-.317.036-.105-.038-.28-.146-.35C6.07 1 5.961 1 5.889 1L5.889 1zM13.875 10c-.099.215-.232.465-.398.571-.132.071-.299.143-.365.285-.099.179 0 .393-.033.571 0 .071-.033.143-.066.215-.033.179 0 .393.066.571.033.107.099.25.199.285.066 0 .166-.035.232-.107.066-.071.132-.215.166-.321s.033-.215.033-.321c.033-.321.199-.643.266-.965.033-.215.033-.429 0-.643C13.942 10.071 13.909 10 13.875 10l.017.035c.009.009.017.018.017.035l-.017-.035C13.884 10.027 13.875 10.018 13.875 10L13.875 10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
3
web/icons/Audio-Wave-1892.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="1.9" d="M19 3L19 22M22 9L22 16M25 11L25 14M16 7L16 18M10 9L10 16M13 11L13 14M7 4L7 21M1 11L1 14M4 8L4 17"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
5
web/icons/Blues-113548.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M4,14c0,0.691,4.925,1,11,1s11-0.309,11-1"/>
|
||||
<path d="M15 12c5.66 0 8-.588 8-.588S22.07 3 19.5 3 17.436 4 15 4s-1.991-1-4.5-1S7 11.412 7 11.412 9.34 12 15 12zM12 24h-2c-1.657 0-3-1.343-3-3v-1c0-1.105.895-2 2-2h3c1.105 0 2 .895 2 2v2C14 23.105 13.105 24 12 24zM16 22v-2c0-1.105.895-2 2-2h3c1.105 0 2 .895 2 2v1c0 1.657-1.343 3-3 3h-2C16.895 24 16 23.105 16 22z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M13 20L17 20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 695 B |
4
web/icons/Book-22940.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M25,27H9c-1.105,0-2-0.895-2-2V7c0-1.105,0.895-2,2-2h16V27z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M7 25c0-1.105.895-2 2-2h16M11 10L22 10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
15
web/icons/C-3PO-31823.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="c3po" viewBox="0 0 48 48">
|
||||
<path fill="#f4b10b" d="M16 39h16v6H16V39zM39 18C39 18 39 18.1 39 18c-.3-3.8-1.8-7.2-4.2-9.8-1.8-1.9-4.1-3.1-6.8-3.8-.8-.4-1.6-.8-2.3-1.4l-1.1-.9c-.4-.3-.9-.3-1.3 0l-1.1.9c-.7.6-1.5 1-2.3 1.4-2.6.6-4.9 1.9-6.7 3.8-2.5 2.5-3.9 5.9-4.2 9.7 0 0 0 0-.1 0 0 0-2 1.7-2 2 .1 2.6.4 4.8.8 7 .1.3 2.3 1 2.3 1 .2 1 .5 2 .8 3l1.1 2h24l1.1-2c.3-1 .5-2 .8-3 0 0 2.2-.7 2.3-1 .5-2.2.8-4.4.9-7C41 19.7 39 18 39 18z"/>
|
||||
<path fill="#b57f08" d="M12,32h24v4H12V32z"/>
|
||||
<path fill="#ffd600" d="M24,42c-3.7,0-6.2-1.5-7.7-2.7c-0.8-0.7-1.4-1.6-1.7-2.6c-1.3-4.9-3.5-10.6-3.6-17 C10.8,12.5,15.3,6,24,6s13.2,6.5,13,13.7c-0.2,6.3-2.3,12.1-3.6,17c-0.3,1-0.8,2-1.7,2.6C30.2,40.5,27.7,42,24,42z"/>
|
||||
<path fill="#684903" d="M26,36h-4c-0.6,0-1-0.4-1-1s0.4-1,1-1h4c0.6,0,1,0.4,1,1S26.6,36,26,36z"/>
|
||||
<path fill="#ffc107" d="M27,32l-3-3l-3,3l-6-6l4-2h10l4,2L27,32z"/>
|
||||
<path fill="#f4b10b" d="M29,18c-2.8,0-4.3,2.3-5,3c-0.7-0.7-2.2-3-5-3s-5,2.2-5,5s2.2,5,5,5c2.9,0,5-3,5-3s2.1,3,5,3 c2.8,0,5-2.2,5-5S31.8,18,29,18z"/>
|
||||
<path fill="#ffea00" d="M22 23c0 1.7-1.3 3-3 3s-3-1.3-3-3 1.3-3 3-3S22 21.3 22 23zM32 23c0 1.7-1.3 3-3 3s-3-1.3-3-3 1.3-3 3-3S32 21.3 32 23z"/>
|
||||
<path fill="#684903" d="M30,23c0,0.6-0.4,1-1,1s-1-0.4-1-1s0.4-1,1-1S30,22.4,30,23z"/>
|
||||
<path fill="#ffd600" d="M25,4c0,0.6-0.4,1-1,1s-1-0.4-1-1s0.4-1,1-1S25,3.4,25,4z"/>
|
||||
<path fill="#684903" d="M20,23c0,0.6-0.4,1-1,1s-1-0.4-1-1s0.4-1,1-1S20,22.4,20,23z"/>
|
||||
<path fill="#dd9f05" d="M12,38c-0.6,0-1-0.4-1-1v-6c0-0.6,0.4-1,1-1s1,0.4,1,1v6C13,37.6,12.6,38,12,38z"/>
|
||||
<path fill="#ffd600" d="M33,46H15c-0.6,0-1-0.4-1-1s0.4-1,1-1h18c0.6,0,1,0.4,1,1S33.6,46,33,46z"/>
|
||||
<path fill="#dd9f05" d="M36,38c-0.6,0-1-0.4-1-1v-6c0-0.6,0.4-1,1-1s1,0.4,1,1v6C37,37.6,36.6,38,36,38z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
3
web/icons/Cannabis-33270.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path d="M28,18.77v-2.557l-1.875-0.256c-0.092-0.013-1.249-0.121-2.58-0.127c1.445-2.708,2-5.083,2.006-5.111l0.311-1.358l-1.166-0.762l-0.071-0.046l-0.993-0.649l-1.046,0.56c-0.252,0.135-1.802,0.987-3.357,2.336c-0.293-2.623-0.967-4.824-1.245-5.53L17.488,4h-1.363h-0.219h-1.33l-0.514,1.226c-0.058,0.138-1.26,3.041-1.557,6.491c-1.651-1.342-4.443-2.884-4.443-2.884l-2.107,1.644l0.201,1.035C6.373,12.529,7.453,15,7.453,15C6.333,15,4,15.393,4,15.393v2.644c0,0,1.875,1.587,4.036,2.731c-0.092,0.087-0.152,0.149-0.179,0.177l-0.94,0.804l0.279,1.075l0.296,1.142L8.645,24.3c0.167,0.043,1.237,0.258,2.41,0.258c0.827,0,1.61-0.106,2.328-0.314c0.075-0.02,0.15-0.041,0.223-0.063c0.211,1.205,0.468,2.166,0.517,2.346L14.527,28H21c-1.292-1.333-2.18-3.417-2.18-3.417c-0.01-0.013-0.019-0.027-0.029-0.042c0.792,0.333,1.833,0.458,2.803,0.481l1.362-0.083l0.547-0.895l0.538-0.919c0,0-0.458-1.083-0.979-1.549c1.541-0.57,3.388-1.408,4.172-2.155L28,18.77z M21.5,19.986c-0.583,0.167-2.268,0.396-2.896,0.473c0,0,2.415,1.398,2.811,2.44c-0.001,0.01,0.006,0.023,0.005,0.034c-0.023-0.001-0.044-0.015-0.068-0.013c-1.875,0.146-4.823-1.502-5.161-1.79c0.072,1.068,0.455,3.57,1.356,4.838L17.519,26h-1.467c0,0-1.095-2.928-1.021-4.969c-0.513,0.437-1.105,1.001-2.205,1.293c-0.609,0.177-1.226,0.235-1.771,0.235c-0.858,0-1.715-0.144-1.913-0.195l-0.01-0.04c0,0,1.422-1.311,3.255-1.748c0.22-0.073,0.44-0.146,0.66-0.146c-0.733-0.146-1.466-0.291-2.273-0.583c-2.406-0.888-4.471-2.46-4.665-2.656L6.105,17.15c0,0,0.767-0.042,1.348-0.042c0.944,0,2.493,0.112,4.127,0.698c0.44,0.146,0.88,0.292,1.247,0.51c-0.607-0.536-3.639-3.066-4.539-6.874l0,0c0,0,4.4,2.266,6.691,5.891c-0.333-1.583-0.539-3.305-0.539-4.131C14.44,9.496,15.906,6,15.906,6h0.219c0.345,0.88,1.246,3.995,1.246,7.203c0,1.087-0.303,3.604-0.365,4.093c0.163-0.388,0.777-1.594,1.607-2.858c1.599-2.434,4.919-4.211,4.919-4.211l0.071,0.046c-0.115,0.501-0.891,3.306-2.637,6.002c-0.435,0.672-1.79,1.949-1.906,2.041c0.205-0.052,0.911-0.329,3.007-0.447c0.504-0.029,0.965-0.039,1.375-0.039c1.295,0,2.411,0.108,2.411,0.108l0.003,0.035C25.244,18.554,23.259,19.482,21.5,19.986z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
6
web/icons/Chapel-69791.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M10.5 7.125L10.494 3.529 7.494 0.5 4.5 3.5 4.5 7.125"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M2.5 13.5L2.5 8.5 7.5 5.5 12.5 8.5 12.5 13.5M7 13.5L2 13.5"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M8.5 13.5v-3c0-.552-.448-1-1-1s-1 .448-1 1v3M13 13.5L8 13.5"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M10.5 10.5L10.5 13.5M4.5 10.5L4.5 13.5M7.5 3.5L7.5 5.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 665 B |
13
web/icons/Chewbacca-89771.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="chewy" viewBox="0 0 48 48">
|
||||
<path fill="#6d4c41" d="M39,25c0-2,0-0.216,0-2C39,9,34,2,24,2S9,10,9,22c0,1.784,0,1,0,3H39z"/>
|
||||
<path fill="#a1887f" d="M39,25L39,25c0-7-4-12-7-12v-3c-3,0-4,2-4,2V8c0,0-3,1-4,3c-1-2-4-3-4-3s0,1,0,3c0,0-1-1-4-1v3 c-3,0-7,5-7,12v0c0,9-2.375,14.125-3,15h5v5l6-3l3,4l4-3l4,3l3-4l6,3v-5h5C41.375,39.125,39,34,39,25z"/>
|
||||
<path fill="#8d6e63" d="M23 29c-.127 0-.877 0-1 0-6 0-7 6-7 6s2-3 4-3 3 0 3 0c.552 0 1-.448 1-1V29zM25 29c.127 0 .877 0 1 0 6 0 7 6 7 6s-2-3-4-3-3 0-3 0c-.552 0-1-.448-1-1V29z"/>
|
||||
<path fill="#212121" d="M29,36H19c-1.1,0-2-0.9-2-2v0c0-1.1,0.9-2,2-2h10c1.1,0,2,0.9,2,2v0C31,35.1,30.1,36,29,36z"/>
|
||||
<path fill="#fff" d="M19 32L20 34 21 32zM27 32L28 34 29 32zM27 36L26 34 25 36zM23 36L22 34 21 36z"/>
|
||||
<path fill="#424242" d="M27,28c0,0.82-0.67,1.63-2,1.9c-0.3,0.07-0.63,0.1-1,0.1s-0.7-0.03-1-0.1c-1.33-0.27-2-1.08-2-1.9 c0-0.5,1-3,3-3S27,27.5,27,28z"/>
|
||||
<path fill="#212121" d="M23 29.5v.4c-1.33-.27-2-1.08-2-1.9h.5C22.33 28 23 28.67 23 29.5zM27 28c0 .82-.67 1.63-2 1.9v-.4c0-.83.67-1.5 1.5-1.5H27z"/>
|
||||
<path fill="#6d4c41" d="M29,18c-1.657,0-3,1.343-3,3s1.343,3,3,3c5,0,6,2,6,2S35,18,29,18z"/>
|
||||
<path fill="#212121" d="M29 20A1 1 0 1 0 29 22A1 1 0 1 0 29 20Z"/>
|
||||
<path fill="#6d4c41" d="M19,18c1.657,0,3,1.343,3,3s-1.343,3-3,3c-5,0-6,2-6,2S13,18,19,18z"/>
|
||||
<path fill="#212121" d="M19 20A1 1 0 1 0 19 22A1 1 0 1 0 19 20Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
6
web/icons/Children-78186.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M9 2A3 3 0 1 0 9 8 3 3 0 1 0 9 2zM6 25.967C6 26.537 6.463 27 7.033 27c.544 0 .995-.422 1.031-.965L8.466 20H6V25.967zM9.936 26.036C9.972 26.578 10.423 27 10.967 27 11.537 27 12 26.537 12 25.967V20H9.534L9.936 26.036zM11 10h-.277C10.376 10.595 9.738 11 9 11s-1.376-.405-1.723-1H7c-1.654 0-3 1.346-3 3v6c0 .553.447 1 1 1s1-.447 1-1v-2h6v2c0 .553.447 1 1 1s1-.447 1-1v-6C14 11.346 12.654 10 11 10zM24 19v-6c0-1.657-1.343-3-3-3s-3 1.343-3 3v6H24zM18 22v3.967C18 26.537 18.463 27 19.033 27c.544 0 .995-.422 1.031-.964L20.333 22H18zM21.667 22l.269 4.035C21.972 26.578 22.423 27 22.967 27 23.537 27 24 26.537 24 25.967V22H21.667zM21 2A3 3 0 1 0 21 8 3 3 0 1 0 21 2z"/>
|
||||
<path d="M26.249 6.751c.827.827.749 2.247.749 2.247s-1.42.078-2.247-.749-.749-2.247-.749-2.247S25.422 5.924 26.249 6.751zM17.998 6.002c0 0 .078 1.42-.749 2.247s-2.247.749-2.247.749-.078-1.42.749-2.247S17.998 6.002 17.998 6.002z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M25,19l-1.515-6.06C23.2,11.8,22.175,11,21,11h0c-1.175,0-2.2,0.8-2.485,1.94L17,19"/>
|
||||
<path d="M24 17L18 17 17 20 25 20z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
7
web/icons/Christmas-Tree-63332.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" id="christmas">
|
||||
<path d="M21.5 17A1.5 1.5 0 1 0 21.5 20 1.5 1.5 0 1 0 21.5 17zM15 12A2 2 0 1 0 15 16 2 2 0 1 0 15 12zM8.5 17A1.5 1.5 0 1 0 8.5 20 1.5 1.5 0 1 0 8.5 17z"/>
|
||||
<path d="M15 14L13.679 12.498 7.634 17.276 8.5 20 15 20zM23.5 25A1.5 1.5 0 1 0 23.5 28 1.5 1.5 0 1 0 23.5 25z"/>
|
||||
<path d="M15 18A2 2 0 1 0 15 22 2 2 0 1 0 15 18zM6.5 25A1.5 1.5 0 1 0 6.5 28 1.5 1.5 0 1 0 6.5 25zM15 6A1 1 0 1 0 15 8 1 1 0 1 0 15 6zM20 11A1 1 0 1 0 20 13 1 1 0 1 0 20 11z"/>
|
||||
<path d="M15 7L15.645 6.241 20.613 11.21 20 13 15 13zM10 11A1 1 0 1 0 10 13 1 1 0 1 0 10 11zM15.146 1.091l.559 1.133 1.25.182c.133.019.187.183.09.277l-.905.882.214 1.245c.023.133-.117.234-.236.171L15 4.393l-1.118.588c-.119.063-.259-.039-.236-.171l.214-1.245-.905-.882c-.096-.094-.043-.258.09-.277l1.25-.182.559-1.133C14.914.97 15.086.97 15.146 1.091zM16.321 18.5L15 21v7h8.5l.869-2.722L16.321 18.5zM18 26c-.552 0-1-.448-1-1 0-.552.448-1 1-1s1 .448 1 1C19 25.552 18.552 26 18 26zM13.679 18.5l-8.049 6.778L6.5 28H15v-7L13.679 18.5zM13 24c-.552 0-1-.448-1-1 0-.552.448-1 1-1s1 .448 1 1C14 23.552 13.552 24 13 24z"/>
|
||||
<path d="M16.32 12.498L15 14v6h6.5l.866-2.724L16.32 12.498zM17 18c-.552 0-1-.448-1-1 0-.552.448-1 1-1s1 .448 1 1C18 17.552 17.552 18 17 18zM14.355 6.241L9.388 11.21 10 13h5v-3c0 .552-.448 1-1 1s-1-.448-1-1c0-.552.448-1 1-1s1 .448 1 1V7L14.355 6.241z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
4
web/icons/Christmas-Tree-66793.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" id="christmas">
|
||||
<path d="M17 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C18 51.448 17.552 51 17 51zM22 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C23 51.448 22.552 51 22 51zM27 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C28 51.448 27.552 51 27 51zM32 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C33 51.448 32.552 51 32 51zM37 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C38 51.448 37.552 51 37 51zM42 51c-.552 0-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1v-2C43 51.448 42.552 51 42 51zM48 52c0-.552-.448-1-1-1s-1 .448-1 1v2c0 .552.448 1 1 1s1-.448 1-1V52z"/>
|
||||
<path d="M54.641,55.858L46.417,44h1.576c0.776,0,1.469-0.434,1.807-1.131c0.336-0.695,0.247-1.503-0.233-2.109L41.036,30h1.946c0.806,0,1.51-0.453,1.84-1.182c0.325-0.721,0.202-1.539-0.321-2.133l-10.769-12.23l2.399,1.359c0.28,0.175,0.625,0.229,0.945,0.146c0.321-0.082,0.601-0.296,0.761-0.582c0.146-0.255,0.196-0.556,0.143-0.848c-0.01-0.053-0.023-0.104-0.042-0.155l-1.492-4.18l3.097-2.849c0.378-0.307,0.541-0.81,0.416-1.283c-0.004-0.016-0.009-0.031-0.014-0.046c-0.148-0.468-0.558-0.804-1.051-0.855l-4.198-0.401L33.143,0.77c-0.125-0.31-0.374-0.558-0.682-0.682c-0.307-0.123-0.653-0.115-0.945,0.017c-0.293,0.129-0.521,0.366-0.648,0.678L29.32,4.76l-4.218,0.4c-0.667,0.068-1.159,0.673-1.097,1.343c0.027,0.306,0.166,0.589,0.395,0.802l3.148,2.895l-1.45,4.185c-0.015,0.043-0.027,0.088-0.036,0.133c-0.13,0.658,0.292,1.309,0.94,1.452C27.092,15.99,27.183,16,27.271,16c0.221,0,0.434-0.059,0.606-0.167l2.373-1.36L19.5,26.685c-0.524,0.595-0.647,1.413-0.321,2.133c0.33,0.729,1.035,1.182,1.84,1.182h1.895l-8.482,10.764c-0.478,0.605-0.566,1.413-0.229,2.107C14.54,43.567,15.231,44,16.006,44h1.544L9.356,55.862c-0.424,0.614-0.472,1.408-0.125,2.069C9.577,58.591,10.254,59,10.998,59H24v3c0,1.103,0.897,2,2,2h12c1.103,0,2-0.897,2-2v-3h13.002c0.744,0,1.422-0.41,1.768-1.071C55.116,57.267,55.067,56.473,54.641,55.858z M28.669,13.075l0.983-2.839c0.13-0.376,0.025-0.794-0.268-1.063L27.01,6.989l3.113-0.295c0.376-0.036,0.701-0.281,0.838-0.633l1.047-2.691l1.047,2.691c0.137,0.353,0.46,0.597,0.837,0.633l3.088,0.295l-2.375,2.184c-0.296,0.272-0.4,0.694-0.265,1.073l1.01,2.828l-2.862-1.622c-0.308-0.173-0.684-0.173-0.991,0.002L28.669,13.075z M26,62v-3h12l0.002,3H26z M10.969,57.046L19.98,44H28c0.552,0,1-0.447,1-1s-0.448-1-1-1l-11.998,0.002L25.46,30h3.492c0.552,0,1-0.448,1-1s-0.448-1-1-1L21,28.006l10.999-12.492L42.982,28h-3.871c-0.152,0-0.292,0.039-0.421,0.1c-0.109,0.036-0.216,0.083-0.311,0.159c-0.433,0.343-0.506,0.972-0.162,1.405L47.992,42h-5.975c-0.553,0-1,0.447-1,1s0.447,1,1,1h1.965l9.019,13L10.969,57.046z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
3
web/icons/Classic-Music-17728.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M6 20L26 20M12 20L12 24M9 20L9 24M20 20L20 24M23 20L23 24M17 20L17 24M20 12l-3.801-4.561C15.439 6.527 14.314 6 13.127 6H10c-2.209 0-4 1.791-4 4v16h20V16c0-2.209-1.791-4-4-4H20z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
6
web/icons/Comedy-5937.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 24 24">
|
||||
<path d="M6,7.5C6,7.5,7.4,7,8.5,7S11,7.5,11,7.5s-0.9,2.1-2.5,2.1S6,7.5,6,7.5z"/>
|
||||
<path d="M19,4v7c0,4.5-5,7.7-7,8.8c-2-1.1-7-4.2-7-8.8V4H19 M19,2H5C3.9,2,3,2.9,3,4c0,0,0,3,0,7c0,7.1,9,11,9,11s9-4,9-11 c0-3.9,0-7,0-7C21,2.9,20.1,2,19,2L19,2z"/>
|
||||
<path d="M15.3,13c0,0-1.8,0.8-3.3,0.8c-1.5,0-3.3-0.8-3.3-0.8s1.2,3,3.3,3C14.2,16,15.3,13,15.3,13L15.3,13z"/>
|
||||
<path d="M19,2h-7v20c0,0,9-4,9-11c0-3.9,0-7,0-7C21,2.9,20.1,2,19,2z M15.5,9.6c-1.6,0-2.5-2.1-2.5-2.1S14.4,7,15.5,7 S18,7.5,18,7.5S17.1,9.6,15.5,9.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
4
web/icons/Country-Music-113286.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M26,14c-1.7,0-3,1.3-3,3c0,3.56-5.5,4-8,4s-8,0-8-4c0-1.7-1.3-3-3-3s-3,1.3-3,3c0,3.9,3.8,7,14,7s14-3.2,14-7C29,15.3,27.7,14,26,14z"/>
|
||||
<path d="M21,8.5C20.296,6.221,19.572,5,18,5c-1.152,0-1.726,1-3,1s-1.848-1-3-1c-1.572,0-2.296,1.221-3,3.5c-0.677,2.19-2,8.144-2,8.144S10.018,18,15,18s8-1.356,8-1.356S21.677,10.69,21,8.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 401 B |
12
web/icons/Darth-Vader-35734.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="darth" viewBox="0 0 48 48">
|
||||
<path fill="#263238" d="M13,16h11h11l8,20v2c0,0-4.813,4-8,4c-3.438,2-11,2.017-11,2.017S16.062,43.75,13,42c-1.438,0-8-2.563-8-4s0-2,0-2L13,16z"/>
|
||||
<path fill="#455a64" d="M44,37c0-2.825-2.049-7.746-4.029-12.504c-0.453-1.089-0.907-2.182-1.323-3.222c-0.02-0.067-0.039-0.128-0.06-0.2c-0.003-0.01-0.006-0.022-0.009-0.032c-0.04-0.135-0.082-0.28-0.126-0.43c-0.014-0.047-0.028-0.096-0.042-0.145c-0.039-0.135-0.079-0.275-0.119-0.418c-0.011-0.039-0.022-0.077-0.033-0.117c-0.051-0.181-0.102-0.368-0.154-0.56c-0.011-0.042-0.023-0.085-0.034-0.127c-0.042-0.155-0.084-0.313-0.126-0.473c-0.015-0.057-0.03-0.113-0.044-0.17c-0.053-0.203-0.105-0.408-0.156-0.615c-0.003-0.013-0.006-0.026-0.009-0.039c-0.048-0.193-0.094-0.387-0.14-0.581c-0.014-0.06-0.028-0.119-0.041-0.179C37.292,16.05,37.076,14.913,37,14c-0.009-0.107-0.039-0.177-0.062-0.262c-0.036-0.345-0.089-0.646-0.156-0.906C35.768,7.811,31.308,4,26,4h-4c-5.308,0-9.768,3.811-10.782,8.832c-0.068,0.261-0.12,0.561-0.156,0.906C11.039,13.823,11.009,13.893,11,14c-0.076,0.913-0.292,2.05-0.554,3.189c-0.014,0.06-0.027,0.119-0.041,0.179c-0.046,0.194-0.092,0.388-0.14,0.581c-0.003,0.013-0.006,0.026-0.009,0.039c-0.051,0.207-0.104,0.413-0.156,0.615c-0.015,0.057-0.03,0.113-0.044,0.17c-0.042,0.16-0.084,0.318-0.126,0.473c-0.011,0.042-0.023,0.085-0.034,0.127c-0.052,0.192-0.104,0.379-0.154,0.56c-0.011,0.04-0.022,0.078-0.033,0.117c-0.041,0.144-0.08,0.284-0.119,0.418c-0.014,0.049-0.028,0.097-0.042,0.145c-0.043,0.15-0.086,0.294-0.126,0.43c-0.003,0.01-0.006,0.022-0.009,0.032c-0.044,0.149-0.085,0.286-0.124,0.414c-0.01,0.032-0.019,0.063-0.028,0.093c-0.031,0.102-0.06,0.197-0.086,0.282c-0.005,0.016-0.01,0.033-0.015,0.048c-0.03,0.096-0.056,0.179-0.078,0.25c-0.006,0.021-0.011,0.036-0.017,0.054c-0.016,0.052-0.032,0.1-0.042,0.131c-0.002,0.006-0.005,0.014-0.006,0.02C9.007,22.393,9,22.417,9,22.417l2.219-0.385c0.21-0.639,0.451-1.182,0.691-1.675c1.466-3.023,3.434-3.272,4.448-3.356H17c2.417,0,5,1,5,1h4c0,0,2.667-1,5-1h0.642c1.014,0.084,2.858,0.25,4.448,3.356c0.104,0.202,0.202,0.423,0.299,0.648c0.06,0.155,0.127,0.32,0.189,0.479c0.068,0.18,0.138,0.353,0.203,0.549l0.016,0.003c0.408,1.019,0.856,2.099,1.327,3.231c1.816,4.363,3.876,9.31,3.879,11.679c-0.005,0.033-0.717,4.041-10.169,5.518C29.726,42.578,26.878,42.509,24,42c-2.91-0.514-5.781-2.536-8-5c0,0-1,3.063-3,5c0,2.209,4.925,3,11,3c5.853,0,10.625-0.7,10.967-2.764c4.615-0.783,6.887-2.043,7.993-3.168C43.966,38.046,44.003,37.152,44,37z"/>
|
||||
<path fill="#546e7a" d="M29,19c-3.625,0-5,3-5,3l1,3c1.031,1.031,5,1,5,1s4,0.125,4-2S32.625,19,29,19z M19,18.999c-3.625,0-5,2.875-5,5s4,2,4,2s3.969,0.031,5-1l1-3C24,21.998,22.625,18.999,19,18.999z M29.978,27.999l-0.016,0c-1.126,0-2.324-0.089-3.482-0.34c0.902,1.35,2.019,3.03,3.147,4.728c0.297,0.447,0.593,0.894,0.886,1.335c0.589-1.479,1.575-3.981,2.349-6.094C32.074,27.866,31.16,28,30.124,28C30.059,28,30.01,27.999,29.978,27.999z M20,33.552V35h1v-2.953c-0.317,0.477-0.637,0.959-0.961,1.446L20,33.552z M28,35h0.961c-0.324-0.488-0.644-0.971-0.961-1.448V35z M29,35.059V35h-0.039C28.974,35.02,28.987,35.039,29,35.059z M22,35h1v-4h-1V35z M26,31v4h1v-2.954c-0.24-0.361-0.467-0.703-0.696-1.046H26z M24,35h1v-4h-1V35z"/>
|
||||
<path fill="#455a64" d="M19 18.999c-.507 0-.965.061-1.387.164 3.448.756 5.258 3.989 5.387 4.839.159-.125.302-.236.449-.351L24 21.999C24 21.998 22.625 18.999 19 18.999zM29 19c-.307 0-.591.028-.866.067 2.86 1.276 3.691 5.761 3.838 6.735C32.981 25.571 34 25.072 34 24 34 21.875 32.625 19 29 19z"/>
|
||||
<path fill="#78909c" d="M26,29h-4v-9.188l0.636,0.489c1.011,0.496,1.699,0.534,2.702,0.022L26,19.813V29z"/>
|
||||
<path fill="#37474f" d="M38.984,22.366c-0.002-0.005-0.004-0.013-0.006-0.02c-0.01-0.031-0.025-0.08-0.042-0.131c-0.006-0.018-0.011-0.034-0.017-0.054c-0.022-0.07-0.048-0.154-0.078-0.25c-0.005-0.015-0.01-0.033-0.015-0.048c-0.026-0.085-0.055-0.181-0.086-0.282c-0.009-0.031-0.019-0.061-0.028-0.093c-0.039-0.128-0.08-0.265-0.124-0.414c-0.003-0.01-0.006-0.022-0.009-0.032c-0.04-0.135-0.082-0.28-0.126-0.43c-0.014-0.047-0.028-0.096-0.042-0.145c-0.039-0.135-0.079-0.275-0.119-0.418c-0.011-0.039-0.022-0.077-0.033-0.117c-0.051-0.181-0.102-0.368-0.154-0.56c-0.011-0.042-0.023-0.085-0.034-0.127c-0.042-0.155-0.084-0.313-0.126-0.473c-0.015-0.057-0.03-0.113-0.044-0.17c-0.053-0.203-0.105-0.408-0.156-0.615c-0.003-0.013-0.006-0.026-0.009-0.039c-0.048-0.193-0.094-0.387-0.14-0.581c-0.014-0.06-0.028-0.119-0.041-0.179C37.292,16.05,37.076,14.913,37,14c-0.009-0.107-0.039-0.177-0.062-0.262c-0.036-0.345-0.089-0.646-0.156-0.906C35.768,7.811,31.308,4,26,4c3.463,1.079,6.733,3.241,7.707,6.189c0.296,0.897-0.436,1.811-1.38,1.805C28.838,11.975,29.132,11.956,26,13v5c0,0,2.667-1,5-1c0.204,0,0.421,0,0.642,0c1.014,0.084,2.941,0.333,4.448,3.356c0.245,0.491,0.481,1.036,0.691,1.675L39,22.417C39,22.417,38.993,22.393,38.984,22.366z"/>
|
||||
<path fill="#455a64" d="M23,22v1h2v-1H23z M23,25h2v-1h-2V25z"/>
|
||||
<path fill="#c5cae9" d="M18,34c-0.552,0-1,0.448-1,1c0,0.552,0.448,1,1,1s1-0.448,1-1C19,34.448,18.552,34,18,34z M24,26c-1.1,0-2,0.9-2,2v2h4v-2C26,26.9,25.1,26,24,26z M30,34c-0.552,0-1,0.448-1,1c0,0.552,0.448,1,1,1s1-0.448,1-1C31,34.448,30.552,34,30,34z"/>
|
||||
<path fill="#607d8b" d="M25,3h-2c-0.552,0-1,0.448-1,1v14h4V4C26,3.448,25.552,3,25,3z M15.677,11.994c1.657-0.009,2.461-0.017,3.185,0.088C19.464,12.171,20,11.705,20,11.098V6.102c0-0.622-0.671-1.022-1.214-0.719c-2.075,1.159-3.769,2.748-4.455,4.695C14.001,11.014,14.684,12,15.677,11.994z M18.022,27.999C17.99,27.999,17.941,28,17.876,28c-1.037,0-1.95-0.134-2.739-0.372c0.775,2.113,1.76,4.615,2.349,6.094c0.292-0.441,0.589-0.888,0.886-1.335c1.128-1.699,2.245-3.379,3.147-4.728c-1.158,0.251-2.356,0.34-3.482,0.34L18.022,27.999z M11.203,22.034l0.016-0.003c0.092-0.276,0.191-0.523,0.29-0.77c0.035-0.09,0.073-0.185,0.108-0.274C13.605,16.383,16.913,16.988,17,17c1.043,0,2.116,0.186,3,0.398v-2.266c0-0.506-0.38-0.937-0.883-0.994C17.89,14.001,17,14,17,14c-5,0-6.268,1.924-6.51,3c-0.361,1.6-0.824,3.225-1.137,4.273c-0.416,1.041-0.87,2.134-1.323,3.223C6.049,29.254,4,34.175,4,37c-0.003,0.152,0.034,1.046,1.04,2.068c1.107,1.125,3.378,2.385,7.994,3.168C13.02,42.158,13,42.082,13,42c0.053-0.051,0.103-0.107,0.154-0.16c-6.602-1.75-7.153-4.868-7.157-4.897C6,34.574,8.06,29.628,9.876,25.265C10.347,24.133,10.795,23.053,11.203,22.034z"/>
|
||||
<path fill="#455a64" d="M37.51,17C37.268,15.924,36,14,31,14c0,0-2.568,0-5,0.677V18c0,0,2.583-1,5-1c0.093-0.013,3.75-0.75,5.781,5.031L39,22.417C39,22.417,38.111,19.666,37.51,17z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
12
web/icons/Disco-Ball-25777.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M10.128,33.863C13.609,34.555,18.971,35,25,35c6.029,0,11.392-0.445,14.872-1.136"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M23.136 17.128C22.445 20.608 22 25.971 22 32c0 6.029.445 11.392 1.136 14.872M26.864 46.872C27.555 43.392 28 38.029 28 32c0-6.029-.445-11.391-1.136-14.872"/>
|
||||
<path d="M33,38.102c0,0,2.563,3.496,3.688,4.949C35.563,44.504,33,48,33,48s3.496-2.563,4.949-3.684C39.402,45.438,42.902,48,42.902,48s-2.566-3.496-3.688-4.949c1.121-1.453,3.688-4.949,3.688-4.949s-3.5,2.563-4.953,3.684C36.496,40.664,33,38.102,33,38.102z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M11.387,38.271C14.839,39.952,19.658,41,25,41c2.121,0,4.154-0.171,6.057-0.476"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M25 17L25 7M27 7h-4c-1.105 0-2-.895-2-2V3h8v2C29 6.105 28.105 7 27 7z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M33.903,34.649C33.963,33.783,34,32.901,34,32c0-5.342-1.048-10.161-2.729-13.613"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M39.051 37.211C39.654 35.586 40 33.835 40 32c0-8.284-6.716-15-15-15-2.367 0-4.597.563-6.588 1.539M10.03 31.399C10.022 31.6 10 31.797 10 32c0 8.284 6.716 15 15 15 2.095 0 4.088-.432 5.899-1.208"/>
|
||||
<path d="M8 13c0 0 2.563 3.496 3.688 4.949C10.563 19.402 8 22.898 8 22.898s3.496-2.563 4.949-3.684c1.453 1.121 4.953 3.684 4.953 3.684s-2.566-3.496-3.688-4.949C15.336 16.496 17.902 13 17.902 13s-3.5 2.563-4.953 3.684C11.496 15.563 8 13 8 13zM13 25c0 0 1.098 1.938 1.797 3.043C14.098 29.117 13 31 13 31s1.91-1.07 3-1.754C17.09 29.93 19 31 19 31s-1.098-1.883-1.797-2.957C17.902 26.938 19 25 19 25s-1.91 1.125-3 1.84C14.91 26.125 13 25 13 25z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M16.018,32.752c0.094,5.039,1.109,9.573,2.711,12.861"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M39.872 30.136C36.391 29.444 31.029 29 25 29c-1.256 0-2.473.023-3.66.06M38.613 25.728C35.161 24.047 30.342 23 25 23c-1.055 0-2.087.045-3.094.124"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
4
web/icons/Electronic-Music-17745.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M5 19L27 19M8 14L15 14M12 19L12 23M9 19L9 23M20 19L20 23M23 19L23 23M17 19L17 23M5 9H27V25H5z"/>
|
||||
<path d="M22.5 13A1.5 1.5 0 1 0 22.5 16 1.5 1.5 0 1 0 22.5 13zM18.5 13A1.5 1.5 0 1 0 18.5 16 1.5 1.5 0 1 0 18.5 13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
4
web/icons/Error-82783.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M21.382,18.967L12.585,4.331c-0.265-0.441-0.904-0.441-1.169,0L2.618,18.967C2.345,19.421,2.672,20,3.202,20h17.595C21.328,20,21.655,19.421,21.382,18.967z"/>
|
||||
<path d="M13,18h-2v-2h2V18z M13,14h-2V9h2V14z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
1
web/icons/Film-Reel-3230.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9,0C4.029,0,0,4.029,0,9s4.029,9,9,9s9-4.029,9-9S13.971,0,9,0z M9,1.751c1.39,0,2.037,0.345,2.037,1.441 c0,1.096-1.612,2.127-2.037,2.127c-0.427,0-2.039-1.031-2.039-2.127C6.961,2.096,7.61,1.751,9,1.751z M2.991,9.299 c-1.043-0.338-1.172-1.06-0.744-2.381c0.429-1.323,0.956-1.834,2-1.497c1.04,0.338,1.527,2.189,1.394,2.594 C5.511,8.42,4.034,9.636,2.991,9.299z M7.345,14.868c-0.602,0.918-1.332,0.856-2.498,0.097c-1.161-0.759-1.516-1.402-0.918-2.319 c0.598-0.919,2.51-0.902,2.867-0.669C7.154,12.209,7.94,13.956,7.345,14.868z M9,10.35c-0.746,0-1.35-0.604-1.35-1.35 S8.254,7.65,9,7.65c0.747,0,1.35,0.604,1.35,1.35S9.747,10.35,9,10.35z M13.231,14.844c-1.132,0.803-1.861,0.899-2.494,0.005 c-0.636-0.893,0.083-2.667,0.427-2.912c0.348-0.249,2.261-0.339,2.893,0.548C14.692,13.381,14.365,14.038,13.231,14.844z M15.005,9.299c-1.042,0.337-2.519-0.879-2.649-1.284c-0.134-0.405,0.354-2.256,1.393-2.594c1.045-0.337,1.572,0.174,2,1.497 C16.177,8.238,16.048,8.961,15.005,9.299z"/><path d="M21,19.489c0-1.499,0.618-2.416,1.335-3.479c0.78-1.157,1.664-2.468,1.664-4.548 c0-3.251-3.609-6.168-6.73-8.084c0.711,1.044,1.227,2.23,1.502,3.505c1.808,1.433,3.228,3.054,3.228,4.579 c0,3.352-2.999,4.243-2.999,8.027c0,3.204,3.535,4.411,3.686,4.46l0.629-1.898C23.291,22.043,21,21.236,21,19.489z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
web/icons/Fridge-282.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26">
|
||||
<path d="M19,2c0.552,0,1,0.448,1,1v19c0,0.552-0.448,1-1,1H7c-0.552,0-1-0.448-1-1V3c0-0.552,0.448-1,1-1H19 M19,0 H7C5.344,0,4,1.344,4,3v19c0,1.656,1.344,3,3,3h12c1.656,0,3-1.344,3-3V3C22,1.344,20.656,0,19,0L19,0z"/>
|
||||
<path d="M9 9C8.449 9 8 8.551 8 8V6c0-.551.449-1 1-1l0 0c.551 0 1 .449 1 1v2C10 8.551 9.551 9 9 9L9 9zM9 18c-.551 0-1-.449-1-1v-3c0-.551.449-1 1-1l0 0c.551 0 1 .449 1 1v3C10 17.551 9.551 18 9 18L9 18zM7 26c-.551 0-1-.448-1-1v-.469h3V25c0 .552-.449 1-1 1H7zM18 26c-.551 0-1-.448-1-1v-.469h3V25c0 .552-.449 1-1 1H18zM5 10H21V12H5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
6
web/icons/Globe-1301.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
5
web/icons/Guitar-110433.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<path d="M51.7 69.2A7 7 0 1 0 51.7 83.2A7 7 0 1 0 51.7 69.2Z"/>
|
||||
<path d="M110.8,21.1l-4-4c-0.4-0.4-0.9-0.8-1.4-1c-2.6-1.4-5.6-0.7-7.4,1.3L90,26.4c-1,1.1-1.5,2.5-1.5,3.8L67.9,50.8c-3.1-2.2-6.7-3.4-10.4-3.4c-4.6,0-8.8,1.8-11.9,5c-0.7,0.7-1.3,1.4-1.8,2.1c-3.3,4.5-8.3,6.7-11.8,7.7c-4.3,1.2-8.2,3.5-11.4,6.7c-7.9,7.9-8.6,20.6-1.7,30.2c6.7,9.4,19,17.9,27.9,17.9h0c7.3,0,14-3.8,18.8-10.8c1.5-2.1,2.4-4.6,3.2-7c1.5-5.2,4.3-9.4,7.9-12.2c0.7-0.6,1.4-1.2,2.1-1.8c3.5-3.5,5.2-7.9,4.9-12.5c-0.1-1.6-1.2-4-2.6-6.4c-2.2-3.8-1.6-8.7,1.5-11.8l15.1-15.1c1.4,0,2.8-0.6,3.9-1.5l9.1-8.1c1.2-1.1,1.9-2.7,2-4.3C112.6,23.9,112,22.3,110.8,21.1z M71.5,77.9c-0.5,0.5-1,0.9-1.5,1.3c-4.7,3.6-8.2,8.9-10.1,15.4c-1,3.3-2.7,6.2-5.1,8.6c-3.2,3.2-6.9,4.8-11.1,4.8c-6.9,0-14.4-4.6-20-12.4c-5.2-7.2-4.7-16.7,1.1-22.5c2.4-2.4,5.5-4.2,8.8-5.1c6.4-1.8,11.6-5.2,15-9.9c0.4-0.5,0.8-1,1.3-1.5c2-2,4.7-3.1,7.6-3.1c2.5,0,5.1,0.8,7.2,2.4c5.9,4.4,9.6,9.7,10,14.3C74.8,73,73.8,75.6,71.5,77.9z M73.2,55.4c-0.2-0.2-0.5-0.5-0.7-0.7l18.8-18.8l0.7,0.7L73.2,55.4z M97.5,33.5C97.5,33.5,97.5,33.5,97.5,33.5l-3.1-3.1l8.1-9.1l4,4L97.5,33.5z"/>
|
||||
<path d="M39,80.4c-1.2-1.2-3.1-1.2-4.2,0c-1.2,1.2-1.2,3.1,0,4.2l8.5,8.5c0.6,0.6,1.4,0.9,2.1,0.9s1.5-0.3,2.1-0.9c1.2-1.2,1.2-3.1,0-4.2L39,80.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
6
web/icons/Hip-Hop Music-17757.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M6,19c0,1.725,0.818,7,3.318,7C11,26,12.5,25,16,25c3.455,0,5,1,6.682,1C25.136,26,26,20.725,26,19"/>
|
||||
<path d="M17.818,6.136C17.818,6.773,17,6.455,16,6.455s-1.818,0.273-1.818-0.318C14.182,5.5,15,5,16,5S17.818,5.5,17.818,6.136z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M16,7C10.5,7,6,9.409,6,14.409c0,0.545,0,4,0,4.591c0,0,4.455-1,10-1c5.5,0,10,1,10,1c0-0.727,0-3.682,0-4.591C26,9.409,21.5,7,16,7z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M18 11.123C18 10.503 17.497 10 16.877 10h-1.754C14.503 10 14 10.503 14 11.123v0c0 .515.351.965.851 1.09l2.299.575c.5.125.851.574.851 1.09v0C18 14.497 17.497 15 16.877 15h-1.754C14.503 15 14 14.497 14 13.877M16 10L16 8M16 17L16 15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 986 B |
4
web/icons/Horror-88855.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M17 20c0 .6-.4 1-1 1s-1-.4-1-1 1-2 1-2S17 19.4 17 20zM21 15.5c0 .8-.7 1.5-1.5 1.5S18 16.3 18 15.5c0-.8 1.5-4 1.5-4S21 14.7 21 15.5z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M9,12l4.5,4c5-5,5.5-11.5,5.5-13L3,17.5L7,21c0,0,3-2,5-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
4
web/icons/Ice-Pop Yellow-94532.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M17,21h-4v5c0,1.105,0.895,2,2,2h0c1.105,0,2-0.895,2-2V21z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M8 10v10c0 .552.448 1 1 1h12c.552 0 1-.448 1-1V10c0-3.866-3.134-7-7-7h0C11.134 3 8 6.134 8 10zM12 10c0-1.657 1.343-3 3-3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
4
web/icons/Jack-o' Lantern-107512.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="halloween" viewBox="0 0 30 30">
|
||||
<path d="M17.532,5c-2.517,0-3.596,0.966-4.435,1.265c-0.779,0.3-2.037,0.12-4.375,0.479c-6.472,1.079-5.693,8.648-5.693,8.648C3.029,23.124,10.64,26,12.498,26c1.258,0,1.318-0.599,1.918-0.599c0.599,0,1.258,0.599,2.517,0.599C21.307,26,27,22.165,27,14.554C27,6.284,20.049,5,17.532,5z M19.721,7.572l1.765,2.353l-1.176,1.765l-4.118,0.588L19.721,7.572z M16.191,14.042l-1.765,1.765l-1.765-1.765H16.191z M9.132,9.336l3.529,2.941H8.544l-1.176-1.176L9.132,9.336z M23.25,20.513c0-0.588-0.588-1.765-0.588-1.765c0,1.176-0.588,2.353-1.177,2.941c-0.176-0.588-0.588-1.176-0.588-1.176c-0.588,1.176-1.177,1.765-1.177,1.765c-0.412-0.647-1.176-1.176-1.176-1.176c0,0.882-0.588,1.765-0.588,1.765c-0.706-0.471-1.177-1.177-1.177-1.177s-0.588,0.824-0.588,1.765c-0.353-0.412-0.941-1.235-1.177-1.765c0,0-0.588,0.588-0.588,1.765c-0.588-0.471-1.765-1.765-1.765-1.765s-0.588,1.177-0.588,1.765c0,0-0.765-0.706-1.176-1.765c0,0-0.588,0.588-0.588,1.177c-1.765-1.177-1.765-2.353-1.765-2.353s-0.588,0.588-0.588,1.765c-1.765-1.765-2.941-3.529-2.941-6.471c0-1.118,0.588-2.941,1.177-3.529c-0.588,4.118,0.588,5.294,0.588,5.294c0-0.235,0-1.177,1.177-2.353c0,2.353,0.588,3.529,0.588,3.529c0-0.588,0.588-1.765,1.176-2.353c0,2.353,1.765,3.529,1.765,3.529c0-0.588,0.588-1.765,1.177-2.353c0,0.588,1.765,2.941,1.765,2.941s0-1.765,0.588-2.941c0.588,1.176,1.765,2.353,1.765,2.353s0.588-1.176,0.588-2.941c0,0,1.765,1.176,2.353,2.941c0,0,1.177-1.176,1.177-4.118c0,0,1.176,1.176,1.176,2.353c0,0,1.176-2.941,0.588-4.118c0,0,1.176,1.176,1.176,2.353c0,0,0.588-2.353-0.588-5.882c1.235,1.176,1.765,2.941,1.765,5.294C25.015,18.16,23.838,19.925,23.25,20.513z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M12.795,7.572C10.998,3.317,15,3,15,3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
5
web/icons/Jack-o' Lantern-66580.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="halloween" viewBox="0 0 16 16">
|
||||
<path fill="#ffab40" d="M4.41,8.67c0-0.008-0.001-0.014-0.001-0.022c-0.006,0.006-0.01,0.012-0.016,0.018C4.399,8.668,4.405,8.669,4.41,8.67z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M5.496,2.5C7.5,2.5,7.5,4.25,7.5,4.25"/>
|
||||
<path d="M9.274,3c-1.26,0-1.819,0.553-2.221,0.71C6.65,3.867,6.039,3.779,4.863,3.943C1.619,4.482,2.015,8.736,2.015,8.736C1.999,12.575,5.808,14,6.754,14c0.63,0,0.63-0.297,0.945-0.297c0.315,0,0.63,0.297,1.26,0.297C11.164,14,14,12.097,14,8.326C14,4.226,10.534,3,9.274,3z M11,5l1,2h-1H9L11,5z M9.5,8L8,9.5L6.5,8H9.5z M5,5l2,2H5H4L5,5z M11,12h-1v-1H9v1H7v-1H6v1H5c0,0-1-0.75-1-2h1v1h1v-1h2v1h1v-1h1v1h1v-1h1C12,11.25,11,12,11,12z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
4
web/icons/Kangaroo-16730.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M5,38c9.729,0,8.167-17.084,12-17c-0.384,5.349,3.147,6.326,2.318,9.197c-0.554,1.918-3.321,3.219-3.318,4.734c0.021,9.363-3,8.264-3,10.224C13,45.791,13.525,46,14,46c1.896,0,4.454-4.814,5-8.021c0.912-5.358,7.467-3.904,9-13.979c0,0,3.192,2.212,5.677,0.153"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M1 35c0 0 1.027 3 4 3C15.001 38 6.699 8 22.991 8c5.301 0 9.484 6 12.009 6 3.309 0 4.445-4.962 4.445-4.962s-1.156-.832-1.39-2.044C37.802 5.68 38.493 4 38.493 4s1.495.673 2.26 2.072c.807 1.476 1.64 2.102 1.64 2.102s1.158.447 2.488 1.68c.945.876 1.524 1.958 1.524 1.958s1.946 1.978 2.397 2.654c.507.761-.231 2.029-.231 2.029l-2.518-.352c0 0-2.491.791-3.806-.669-2.336-.876-1.921 4.255-5.009 6.828-1.447 1.206-1.503 3.946-1.689 5.367-.181 1.387-1.749 3.015-3.136 2.473M42.425 3.988c1.501 2.068.345 3.182 1.282 4.769M35.061 20.143c-3.371 4.933.887 7.595-.426 9.263"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
15
web/icons/Luke-Skywalker-39424.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="luke" viewBox="0 0 48 48">
|
||||
<path fill="#6d4c41" d="M31,37c0,0,0.6,3,4,3c-1.8,1.6-4.3,1.7-5,1S31,37,31,37z"/>
|
||||
<path fill="#6d4c41" d="M30 38c0 0-.9 2.7 2 2 8.6-2 13-16 13-16H30V38zM16.8 37c0 0-.6 3-4 3 1.8 1.6 4.3 1.7 5 1S16.8 37 16.8 37z"/>
|
||||
<path fill="#6d4c41" d="M17.8,38c0,0,0.9,2.7-2,2c-8.6-2-13-16-13-16h15V38z"/>
|
||||
<path fill="#fb8c00" d="M16,33h16v13H16V33z"/>
|
||||
<path fill="#bbdefb" d="M16 40l-2 6h7L16 40zM32 40l2 6h-7L32 40z"/>
|
||||
<path fill="#ffa726" d="M35.8,24.7c0.2-0.8,0.5-1.5,1-2.1c0.4-0.5,1.1-0.8,1.6-0.5c0.5,0.3,0.6,1,0.6,1.6c0,1,0,2.1,0,3.1 c0,0.6,0,1.2-0.2,1.7c-0.2,0.6-0.5,1.1-0.9,1.6c-0.3,0.4-0.6,0.8-0.9,1.2c-0.3,0.4-0.6,0.7-1,0.6c-0.1,0-0.3-0.1-0.4-0.3 c-0.2-0.2-0.4-0.4-0.7-0.6L35.8,24.7z M12.2,24.7c-0.2-0.8-0.5-1.5-1-2.1c-0.4-0.5-1.1-0.8-1.6-0.5c-0.5,0.3-0.6,1-0.6,1.6 c0,1,0,2.1,0,3.1c0,0.6,0,1.2,0.2,1.7c0.2,0.6,0.5,1.1,0.9,1.6c0.3,0.4,0.6,0.8,0.9,1.2c0.3,0.4,0.6,0.7,1,0.6 c0.1,0,0.3-0.1,0.4-0.3c0.2-0.2,0.4-0.4,0.7-0.6L12.2,24.7z"/>
|
||||
<path fill="#fb8c00" d="M38,26c0,2.1-1,3-1,3v-2c-0.6,0-1-0.4-1-1s0.4-1,1-1S38,25.4,38,26z M10,26c0,2.1,1,3,1,3v-2 c0.6,0,1-0.4,1-1s-0.4-1-1-1S10,25.4,10,26z"/>
|
||||
<path fill="#ffb74d" d="M22,43c-3.1,0-10-7.8-10-11V15c0,0,0.3-8,12-8s12,8,12,8v17c0,3.1-7.1,11-10,11H22z"/>
|
||||
<path fill="#fb8c00" d="M20 34H28V36H20z"/>
|
||||
<path fill="#8d6e63" d="M39.9,16.9C39.4,11,36.6,2.1,29,2c-2.1,0-3,1-3,1c-1-0.8-3-1-3-1c-9.2,0-13,7.5-13,18l3,1.3 c0,0,8-3,8-13.3c0,11.1,14,12,14,12v1.3c0,3.7,1.3,6.8,5,6.8c0,0,3.4,0.1,5-4C41.1,24,40,17.5,39.9,16.9"/>
|
||||
<path fill="#8d6e63" d="M8.1,16.9C8.6,11,14.4,2.1,22,2c2.1,0-9,17-9,17v2.3C13,25,11.7,28,8,28c0,0-3.4,0.1-5-4 C6.9,24,8,17.5,8.1,16.9"/>
|
||||
<path fill="#784719" d="M31 24.5c0 .8-.7 1.5-1.5 1.5S28 25.3 28 24.5s.7-1.5 1.5-1.5S31 23.7 31 24.5M20 24.5c0 .8-.7 1.5-1.5 1.5S17 25.3 17 24.5s.7-1.5 1.5-1.5S20 23.7 20 24.5"/>
|
||||
<path fill="#8d6e63" d="M21,9c0,0-1.3,12,8,12c-8.1-2.9-6-11-6-11L21,9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
5
web/icons/Metal-Music-17763.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M10,15V6c0-1.105,0.895-2,2-2h0c1.105,0,2,0.895,2,2v7"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M10,15v3.172l-2.586-2.586c-0.781-0.781-2.047-0.781-2.828,0c-0.781,0.781-0.781,2.047,0,2.828l6.791,6.772C12.421,26.226,13.779,27,15.375,27H20c3.314,0,6-2.686,6-6V7c0-1.105-0.895-2-2-2s-2,0.895-2,2v6"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M20 10L20 10c-1.105 0-2 .895-2 2v4c0 1.105.895 2 2 2h0c1.105 0 2-.895 2-2v-4C22 10.895 21.105 10 20 10zM16 10L16 10c-1.105 0-2 .895-2 2v4c0 1.105.895 2 2 2h0c1.105 0 2-.895 2-2v-4C18 10.895 17.105 10 16 10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 765 B |
4
web/icons/Mushroom-63864.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M2.5,8c0,3.038,2.462,0.5,5.5,0.5s5.5,2.538,5.5-0.5S11.038,2.5,8,2.5S2.5,4.962,2.5,8z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M9.152,8.625C9.369,10.951,9.5,12.135,9.5,12.5c0,0.828-0.672,1-1.5,1s-1.5-0.172-1.5-1c0-0.365,0.131-1.549,0.348-3.875"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
4
web/icons/Music-14097.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M10 24L10 8 26 5 26 21M10 12L26 9"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M23 18A3 3 0 1 0 23 24 3 3 0 1 0 23 18zM7 21A3 3 0 1 0 7 27 3 3 0 1 0 7 21z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
5
web/icons/Music-Conductor-225.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M31,35v12c0,1.102-0.898,2-2,2h-8c-1.102,0-2-0.898-2-2V35"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M25 1A5 5 0 1 0 25 11 5 5 0 1 0 25 1zM46.381 13.616c-.824-.822-2.159-.822-2.985 0l-5.263 5.251-5.265-5.251C32.229 12.979 27.316 13 26.838 13L25 24l-1.838-11c-.478 0-5.391-.021-6.03.616l-5.265 5.251-5.263-5.251c-.826-.822-2.161-.822-2.985 0-.826.824-.826 2.156 0 2.979l6.756 6.739c.411.412.951.616 1.492.616.542 0 1.081-.204 1.494-.616L18 19v16h14V19l4.639 4.334c.413.412.951.616 1.493.616.54 0 1.081-.204 1.492-.616l6.756-6.739C47.206 15.772 47.206 14.44 46.381 13.616z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M44.109 13.109L36.308 5.308"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 980 B |
6
web/icons/Music-Record-102104.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 11A1 1 0 1 0 12 13A1 1 0 1 0 12 11Z"/>
|
||||
<path d="M12,18c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S15.309,18,12,18z M12,8c-2.206,0-4,1.794-4,4s1.794,4,4,4s4-1.794,4-4S14.206,8,12,8z"/>
|
||||
<path d="M12,22C6.486,22,2,17.514,2,12S6.486,2,12,2s10,4.486,10,10S17.514,22,12,22z M12,4c-4.411,0-8,3.589-8,8s3.589,8,8,8s8-3.589,8-8S16.411,4,12,4z"/>
|
||||
<path d="M8.566 14.02C8.215 13.425 8 12.741 8 12c0-2.209 1.791-4 4-4 .741 0 1.425.215 2.02.566l4.304-4.304C16.599 2.85 14.397 2 12 2 6.486 2 2 6.486 2 12c0 2.397.85 4.599 2.262 6.324L8.566 14.02zM15.434 9.98C15.785 10.575 16 11.259 16 12c0 2.209-1.791 4-4 4-.741 0-1.425-.215-2.02-.566l-4.304 4.304C7.401 21.15 9.603 22 12 22c5.514 0 10-4.486 10-10 0-2.397-.85-4.599-2.262-6.324L15.434 9.98z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 822 B |
1
web/icons/Music-Record-3397.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 10.9A1.1 1.1 0 1 0 12 13.1 1.1 1.1 0 1 0 12 10.9zM7.7 14.5C7.3 13.8 7 12.9 7 12c0-2.8 2.2-5 5-5 .9 0 1.8.3 2.5.7L18 1.6c-1.8-1-3.8-1.6-6-1.6C5.4 0 0 5.4 0 12c0 2.2.6 4.3 1.6 6L7.7 14.5zM22.4 6l-6.1 3.5c.4.7.7 1.6.7 2.5 0 2.8-2.2 5-5 5-.9 0-1.8-.3-2.5-.7L6 22.4c1.8 1 3.8 1.6 6 1.6 6.6 0 12-5.4 12-12C24 9.8 23.4 7.7 22.4 6z"/><g><path d="M12,1.5c5.8,0,10.5,4.7,10.5,10.5S17.8,22.5,12,22.5S1.5,17.8,1.5,12S6.2,1.5,12,1.5 M12,17.5 c3,0,5.5-2.5,5.5-5.5S15,6.5,12,6.5S6.5,9,6.5,12S9,17.5,12,17.5 M12,0C5.4,0,0,5.4,0,12c0,6.6,5.4,12,12,12s12-5.4,12-12 C24,5.4,18.6,0,12,0L12,0z M12,16c-2.2,0-4-1.8-4-4s1.8-4,4-4s4,1.8,4,4S14.2,16,12,16L12,16z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 723 B |
4
web/icons/New-47652.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M1.929 5h1.362l1.049-.338c.153-.05.273-.169.323-.323l.44-1.366 1.403.302c.156.033.321-.01.44-.118L8.01 2.194l1.065.963c.119.107.282.149.44.118l1.402-.302.44 1.366c.049.153.169.273.322.323L12.729 5h1.362l.028-.13c.054-.251-.092-.502-.336-.581l-1.552-.5L11.73 2.236c-.078-.243-.326-.39-.581-.335L9.556 2.244l-1.21-1.095c-.19-.172-.48-.172-.671 0l-1.21 1.094L4.87 1.9c-.25-.057-.502.091-.581.335l-.5 1.553-1.552.5c-.245.079-.39.33-.335.581L1.929 5zM14.087 11h-1.295l-1.111.358c-.153.049-.273.169-.322.322l-.44 1.365-1.403-.301c-.155-.032-.32.011-.439.118L8.01 13.826l-1.064-.963c-.093-.084-.213-.129-.335-.129-.035 0-.07.004-.105.011l-1.403.301-.44-1.365c-.05-.153-.169-.273-.323-.322L3.228 11H1.934l-.032.15c-.054.251.091.502.335.58l1.552.501.5 1.552c.079.244.332.384.581.336l1.594-.342 1.21 1.094C7.77 14.957 7.89 15 8.01 15s.24-.043.335-.129l1.21-1.094 1.595.342c.255.05.502-.092.58-.336l.501-1.552 1.552-.501c.244-.078.39-.329.336-.58L14.087 11z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" d="M2.5 6L2.5 10M4.5 6L4.5 10M2.5 7.25L4.5 8.75M9 9.5L6.5 9.5 6.5 6.5 8.5 6.5 8.5 7.75 6.5 8.5M10.5 6L10.5 9.375 12 8.5 13.5 9.375 13.5 6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
8
web/icons/Old-Woman-77881.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M5 16v2c0 2.5 2 3 2 3s2 6 8 6M25 16v2c0 2.5-2 3-2 3s-2 6-8 6"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M11 20L11 20c-1.105 0-2-.895-2-2v-1c0-.552.448-1 1-1h2c.552 0 1 .448 1 1v1C13 19.105 12.105 20 11 20zM19 20L19 20c-1.105 0-2-.895-2-2v-1c0-.552.448-1 1-1h2c.552 0 1 .448 1 1v1C21 19.105 20.105 20 19 20z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M24,15c0-0.512,0-0.488,0-1c0-4.971-4.029-9-9-9s-9,4.029-9,9c0,0.512,0,0.488,0,1"/>
|
||||
<path d="M15 4v4c0 4 8 3 8 7 2 0 2 4 2 4s2-3 2-6S24 4 15 4zM15 4v4c0 4-8 3-8 7-2 0-2 4-2 4s-2-3-2-6S6 4 15 4z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M13 17L17 17"/>
|
||||
<path d="M15 1A5 3.5 0 1 0 15 8A5 3.5 0 1 0 15 1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1008 B |
1
web/icons/Opera-Glasses-102740.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M20 5A3 3 0 1 0 20 11 3 3 0 1 0 20 5zM15 15c-1.657 0-3 1.343-3 3s1.343 3 3 3 3-1.343 3-3S16.657 15 15 15zM15 19c-.552 0-1-.448-1-1 0-.552.448-1 1-1s1 .448 1 1C16 18.552 15.552 19 15 19z"/><path d="M29.094 15.202l-3.277-5.664c-.537-.925-1.525-1.495-2.595-1.495H15V17h2.181C17.067 17.482 17 17.983 17 18.5c0 3.59 2.91 6.5 6.5 6.5s6.5-2.91 6.5-6.5C30 17.294 29.666 16.169 29.094 15.202zM23.5 23c-2.485 0-4.5-2.015-4.5-4.5s2.015-4.5 4.5-4.5 4.5 2.015 4.5 4.5S25.985 23 23.5 23zM10 5A3 3 0 1 0 10 11 3 3 0 1 0 10 5z"/><path d="M0,18.5C0,22.09,2.91,25,6.5,25s6.5-2.91,6.5-6.5c0-0.517-0.067-1.018-0.181-1.5H15V8.043H6.778c-1.07,0-2.058,0.569-2.595,1.495l-3.277,5.664C0.334,16.169,0,17.294,0,18.5z M2,18.5C2,16.015,4.015,14,6.5,14s4.5,2.015,4.5,4.5S8.985,23,6.5,23S2,20.985,2,18.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 851 B |
5
web/icons/Pills-112386.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<path d="M66.4,103.2c2.1,12,12.3,20.8,24.1,20.8c1.4,0,2.7-0.1,4.1-0.4c6.4-1.1,11.9-4.8,15.6-10.2c3.7-5.4,5-12,3.9-18.6C112,82.8,101.9,74,90,74c-1.4,0-2.7,0.1-4.1,0.4C72.8,76.7,64,89.7,66.4,103.2z M87,80.3c1-0.2,2-0.3,3.1-0.3c8.9,0,16.6,6.7,18.2,15.8c0.9,5-0.2,10.1-3,14.2c-2.8,4.1-6.9,6.8-11.7,7.7c-1,0.2-2,0.3-3.1,0.3c-8.9,0-16.6-6.7-18.2-15.8C70.5,91.9,77.1,82.1,87,80.3z"/>
|
||||
<path d="M83.7 109.9c.5.4 1.2.6 1.8.6 1 0 2-.5 2.6-1.4l9.6-16.4c1-1.4.6-3.4-.8-4.4-.5-.4-1.2-.6-1.8-.6-1 0-2 .5-2.6 1.4l-9.6 16.4C81.9 106.9 82.3 108.9 83.7 109.9zM20.8 120.9L21 121c3 1.7 6.4 2.6 9.8 2.6 7 0 13.5-3.8 17-9.8l11.7-20.3c0 0 0 0 0 0 0 0 0 0 0 0l11.7-20.3c5.4-9.4 2.2-21.4-7.2-26.8l-.1-.1c-3-1.7-6.4-2.6-9.8-2.6-7 0-13.5 3.8-17 9.8L25.4 73.8c0 0 0 0 0 0 0 0 0 0 0 0L13.7 94.1C8.2 103.5 11.5 115.5 20.8 120.9zM42.3 56.4c2.4-4.2 6.9-6.8 11.8-6.8 2.4 0 4.7.6 6.8 1.8l.1.1c6.5 3.8 8.7 12.1 5 18.6L55.8 87.9 32.1 74.2 42.3 56.4zM18.8 97.1l10.2-17.7 17.4 10.1-10.3 17.9c-2.8 4.8-7.5 7.7-12.6 8.2C17.3 111.7 15.2 103.5 18.8 97.1z"/>
|
||||
<path d="M50.4,56.6c-1.4-0.8-3.3-0.3-4.1,1.1l-7,12.1c-0.8,1.4-0.3,3.3,1.1,4.1c0.5,0.3,1,0.4,1.5,0.4c1,0,2-0.5,2.6-1.5l7-12.1C52.3,59.2,51.8,57.4,50.4,56.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
web/icons/Pills-92954.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M13,21c0-2.57,1.217-4.851,3.1-6.314l-3.873-3.873c-0.391-0.391-0.391-1.024,0-1.414l2.664-2.664c1.154-1.154,2.671-1.731,4.188-1.731c1.516,0,3.033,0.577,4.187,1.731c1.887,1.887,2.205,4.73,1.007,6.971c0.622,0.279,1.202,0.634,1.727,1.055c1.669-3.012,1.233-6.886-1.32-9.44c-1.616-1.616-3.808-2.448-6.113-2.304c-2.004,0.125-3.868,1.082-5.288,2.502l-7.735,7.735c-3.084,3.084-3.363,8.154-0.345,11.302C6.708,26.13,8.748,27,10.921,27c1.367,0,2.679-0.349,3.842-0.996C13.662,24.633,13,22.895,13,21z"/>
|
||||
<path d="M21,15c-3.314,0-6,2.686-6,6s2.686,6,6,6s6-2.686,6-6S24.314,15,21,15z M22.414,23.828l-4.243-4.243c-0.391-0.391-0.39-1.024,0-1.414c0.391-0.391,1.024-0.391,1.414,0l4.243,4.243c0.391,0.391,0.391,1.024,0,1.414C23.438,24.219,22.805,24.219,22.414,23.828z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 829 B |
24
web/icons/Princess-Leia-68568.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="leia" viewBox="0 0 100 100">
|
||||
<path fill="#fff" d="M20.3 56.1A.9.9 0 1 0 20.3 57.9.9.9 0 1 0 20.3 56.1zM79.3 31.400000000000002A.7.7 0 1 0 79.3 32.800000000000004.7.7 0 1 0 79.3 31.400000000000002z"/>
|
||||
<path fill="#f1bc19" d="M76.7 12.2A.9.9 0 1 0 76.7 14 .9.9 0 1 0 76.7 12.2zM20.7 63.800000000000004A.9.9 0 1 0 20.7 65.60000000000001.9.9 0 1 0 20.7 63.800000000000004z"/>
|
||||
<path fill="#f9dbd2" d="M49.6 12.799999999999997A37.5 37.5 0 1 0 49.6 87.8A37.5 37.5 0 1 0 49.6 12.799999999999997Z"/>
|
||||
<path fill="#f1bc19" d="M82.1 11.2A4 4 0 1 0 82.1 19.2A4 4 0 1 0 82.1 11.2Z"/>
|
||||
<path fill="#ee3e54" d="M86.1 22.400000000000002A1.9 1.9 0 1 0 86.1 26.2A1.9 1.9 0 1 0 86.1 22.400000000000002Z"/>
|
||||
<path fill="#fbcd59" d="M77.1 74.8A1.9 1.9 0 1 0 77.1 78.60000000000001 1.9 1.9 0 1 0 77.1 74.8zM14 59.8A4 4 0 1 0 14 67.8 4 4 0 1 0 14 59.8z"/>
|
||||
<path fill="#ee3e54" d="M24.2 86A1.9 1.9 0 1 0 24.2 89.80000000000001A1.9 1.9 0 1 0 24.2 86Z"/>
|
||||
<path fill="#fff" d="M75.8 28.8A1.8 1.8 0 1 0 75.8 32.4 1.8 1.8 0 1 0 75.8 28.8zM17.7 49.2A2.5 2.5 0 1 0 17.7 54.2 2.5 2.5 0 1 0 17.7 49.2z"/>
|
||||
<path fill="#51302f" d="M62.9 35.8A8.3 12.3 0 1 0 62.9 60.400000000000006 8.3 12.3 0 1 0 62.9 35.8zM37.9 35.8A8.3 12.3 0 1 0 37.9 60.400000000000006 8.3 12.3 0 1 0 37.9 35.8z"/>
|
||||
<path fill="#d8ad8f" d="M61.3,44.8c0.1-1.4,0.5-3.1,0.6-3.8l-1.1-3.1c-1.3-1.6-2.1-3-3.8-4.1c-1.9-1.2-4.4-0.5-6.6-1 c-5.4-1.4-10.4,4.1-12,9.2c0,0,0,0.1,0,0.1c0.2,0.9,0.3,1.9,0.3,2.8c-1.5,0-2.2,0.8-1.7,2.3c0,0,0.6,2.5,0.9,3.7 c0.1,0.7,0.7,1.2,1.4,1.2c0,0.8,0,1.6,0,2.4c0,1,0.3,1.9,0.8,2.7c1.4,2.1,8.8,6.8,10.5,6.7c2.9,0,8.6-4.9,10-7.3 c0.3-0.5,0.4-1,0.5-1.6c0-0.9,0-1.9,0-2.8c0.6-0.1,1.1-0.5,1.2-1.1c0.4-1.2,0.9-3.7,1-3.7C63.8,45.5,62.7,44.7,61.3,44.8z"/>
|
||||
<path fill="#b2876b" d="M65.6,69l-0.3-0.1l-0.5-0.2c-1.2-0.5-8.8-3.1-8.8-3.1c-0.5-0.1-0.9-0.5-1.2-1 c-0.2-0.9-0.3-1.8-0.2-2.8c-1.3,0.6-2.6,1-4,1.3c-1.8,0.2-4.9-2.1-4.9-2.1s-0.1,2.9-0.2,3.5c-0.2,0.6-0.6,1-1.2,1.1L37,68 c-0.7,0.2-1.4,0.5-2.1,0.9c-1.6,1-2.7,2.6-3.1,4.4l0,0c0,0.2-0.1,0.4-0.1,0.6c-0.1,0.4-0.1,0.8-0.1,1.3c0,0.4-0.1,6.9-0.1,7.4 c6.5,3.8,11,5.1,21,5.1c4.4,0,13.3-3.9,16.7-5.7C68.8,73.1,69.7,70.3,65.6,69z"/>
|
||||
<path fill="#fdfcef" d="M65.6,69.1L65.3,69l-0.5-0.2c-1-0.4-6.2-2.2-8.1-2.9c-3.6,1.2-7.4,0.9-11.1,0.9 c-0.8,0-1.5-0.3-2-0.8L37,68.2c-2.6,0.6-4.6,2.7-5.2,5.3l0,0c0,0.2-0.1,0.4-0.1,0.6c-0.1,0.4-0.1,0.8-0.1,1.3 c0,0.4-0.1,6.9-0.1,7.4c6.6,3.8,11.1,5.1,21.1,5.1c4.4,0,13.3-3.9,16.7-5.7C68.8,73.2,69.7,70.4,65.6,69.1z"/>
|
||||
<path fill="#472b29" d="M39.6 52.6c-.1 0-.2-.1-.2-.2l-.8-7.6c0-.1.1-.2.2-.2.1 0 .2.1.2.2l.8 7.6C39.8 52.5 39.7 52.6 39.6 52.6 39.6 52.6 39.6 52.6 39.6 52.6zM61 52.2C61 52.2 61 52.2 61 52.2c-.1 0-.2-.1-.2-.2l.5-7.7c0-.1.1-.2.2-.2.1 0 .2.1.2.2L61.2 52C61.2 52.1 61.1 52.2 61 52.2zM50.6 63.3c-1.9 0-4.8-1.6-4.9-1.7-.1-.1-.2-.2-.1-.3.1-.1.2-.2.3-.1 0 0 3.2 1.7 4.9 1.6 1.4-.3 2.8-.8 4.1-1.5.1-.1.3 0 .3.1.1.1 0 .3-.1.3-1.3.8-2.7 1.3-4.2 1.6C50.8 63.3 50.7 63.3 50.6 63.3z"/>
|
||||
<path fill="#472b29" d="M50.4,52.6c0.8,0,1.5-0.3,2.1-0.9c0-0.1,0-0.1,0-0.2l0,0 c-0.1-0.1-0.1-0.1-0.2,0c0,0,0,0,0,0c-1,1-2.7,1-3.7,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0.1,0,0.1,0,0.2 C48.9,52.3,49.6,52.6,50.4,52.6z" opacity=".41"/>
|
||||
<path fill="#472b29" d="M47.9 42.2l.5-1c-2.2-.5-4.5-.4-6.6.2l.6.9C44.2 41.8 46.1 41.8 47.9 42.2zM58.6 42.2l.5-1c-2.2-.5-4.5-.4-6.6.2l.6.9C54.9 41.8 56.8 41.8 58.6 42.2zM44.6 43.6c-.5 0-1 .4-1.2.8.1 0 .1 0 .2 0 .5.1.8.5.7 1-.1.4-.3.7-.7.7-.1 0-.3 0-.4-.1.1.7.7 1.3 1.4 1.3 1-.3 1.7-1.3 1.4-2.3C45.8 44.3 45.3 43.8 44.6 43.6L44.6 43.6zM55.8 43.6c-.5 0-.9.3-1.1.6.1 0 .2-.1.3-.1.5.1.8.5.7 1-.1.4-.3.7-.7.7-.3 0-.5-.1-.6-.4.2.8 1.1 1.3 1.9 1.1s1.3-1.1 1.1-1.9C57.1 44.1 56.5 43.6 55.8 43.6zM56.6 55.1c.1-.1.1-.1 0-.2 0 0 0 0 0 0l0 0c-.1-.1-.1-.1-.2 0 0 0 0 0 0 0-1.8 1.2-3.9 1.8-6 1.7-2.1.1-4.2-.5-6-1.7-.1-.1-.1-.1-.2 0-.1.1-.1.1 0 .2 0 0 0 0 0 0C47.9 57.7 52.8 57.7 56.6 55.1L56.6 55.1z"/>
|
||||
<path fill="#cd9480" d="M43.2 49.5A2.2 2.2 0 1 0 43.2 53.900000000000006 2.2 2.2 0 1 0 43.2 49.5zM57.4 49.5A2.2 2.2 0 1 0 57.4 53.900000000000006 2.2 2.2 0 1 0 57.4 49.5z"/>
|
||||
<path fill="#472b29" d="M49.9,88.2c-6.5,0-13-1.7-18.7-5.2c-0.1-0.1-0.2-0.2-0.2-0.4c-0.1-5.4,0.4-12.3,3.8-14 c0.7-0.4,1.4-0.7,2.1-0.9l7.3-2.5c0.5-0.1,0.8-0.4,0.9-0.7c0.1-0.4,0.2-1.9,0.2-2.7c-2.1-1.3-4.7-3.2-5.5-4.5 c-0.6-0.9-0.9-1.9-0.9-3l0-1.9c-0.7-0.2-1.2-0.7-1.4-1.5c-0.4-1.1-0.9-3.6-0.9-3.7c-0.4-1-0.1-1.7,0.2-2.1 c0.2-0.3,0.7-0.7,1.5-0.8c0-0.1,0-0.3,0-0.4c-0.3-0.5-0.4-1.1-0.5-1.7C37.7,36.1,43.2,31,50,31c6.8,0,12.3,5,12.3,11.2 c0,0.7-0.2,1.3-0.5,1.8c0,0.1,0,0.2,0,0.3c0.8,0.1,1.4,0.4,1.8,0.9c0.3,0.4,0.5,1,0.2,2c-0.1,0.6-0.7,2.8-0.9,3.7 c-0.1,0.6-0.6,1.1-1.2,1.4v2.4c0,0.7-0.2,1.3-0.5,1.8c-1.7,2.3-3.7,4.2-6.1,5.8c0,0.7,0,1.4,0.2,2.1c0.1,0.3,0.4,0.5,0.8,0.6 c0.3,0.1,7.7,2.6,8.9,3.1l0.9,0.4c3.6,1.2,3.7,5.8,3.8,12.3l0,1c0,0.2-0.1,0.3-0.2,0.4C63.6,86.3,56.7,88.2,49.9,88.2z M31.9,82.3c11.3,6.8,25.7,6.5,36.8-0.6l0-0.7c-0.1-6.3-0.2-10.5-3.2-11.4c0,0-0.1,0-0.1,0l-0.3-0.2l-0.5-0.2 c-1.1-0.5-8.7-3.1-8.8-3.1c-0.6-0.2-1.2-0.6-1.4-1.3c-0.3-1-0.3-1.9-0.3-2.8c0-0.2,0.1-0.3,0.2-0.4c2.3-1.5,4.3-3.4,6-5.6 c0.2-0.4,0.3-0.8,0.3-1.3V52c0-0.3,0.2-0.5,0.4-0.5c0.4-0.1,0.7-0.3,0.8-0.7c0.4-1.2,0.9-3.6,0.9-3.7c0.2-0.5,0.1-0.9-0.1-1.2 c-0.3-0.3-0.8-0.5-1.5-0.5c-0.1,0-0.3,0-0.4-0.1c-0.1-0.1-0.2-0.2-0.2-0.4c0-0.7,0-1.1,0.2-1.3c0.2-0.4,0.3-0.9,0.3-1.3 C61.2,36.6,56.2,32,50,32c-6.2,0-11.3,4.6-11.3,10.3c0,0.4,0.2,0.9,0.4,1.3c0,0,0,0.1,0,0.2c0,0.4,0.1,0.7,0.1,1.1 c0,0.1,0,0.3-0.1,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.6,0-1,0.1-1.2,0.4c-0.2,0.3-0.1,0.8,0,1.2c0,0.1,0.6,2.5,0.9,3.7 c0.1,0.5,0.4,0.8,0.9,0.8c0.1,0,0.3,0.1,0.4,0.1c0.1,0.1,0.1,0.2,0.1,0.4v2.4c0,0.9,0.2,1.7,0.7,2.4c0.7,1,3,2.8,5.5,4.3 c0.1,0.1,0.2,0.3,0.2,0.4c0,0.7-0.2,2.6-0.3,3.2c-0.2,0.8-0.8,1.3-1.6,1.5l-7.3,2.5c-0.7,0.2-1.4,0.5-2,0.8 C32.9,70.6,31.8,75.2,31.9,82.3z"/>
|
||||
<path fill="#472b29" d="M50.4,30.5c-7,0-12.7,5.2-12.7,11.6c0,0.6,0.2,1.2,0.5,1.8c1.5-5.7,8.6-8.4,12.2-8.4 s10.6,2.4,12.2,8.4c0.3-0.6,0.4-1.2,0.4-1.8C63.1,35.7,57.4,30.5,50.4,30.5z"/>
|
||||
<path fill="#472b29" d="M50.4,28.3c-7.6,0-13.8,5.2-13.8,11.6c0,0.6,0.2,1.2,0.5,1.8c1.7-5.7,9.4-8.4,13.3-8.4 s11.6,2.4,13.3,8.4c0.3-0.6,0.4-1.2,0.5-1.8C64.2,33.5,58,28.3,50.4,28.3z"/>
|
||||
<path fill="#472b29" d="M51.2 34.6c.3.1.5.2.7.3 1.3.7 2.4 2 2.8 3.4.5 1.4.8 2.8 1.5 4.1.6 1.5 1.9 2.6 3.4 3 1.4.2 2.8.2 4.2-.2.3-1.2.3-2.5.2-3.8-.1-1.4-.6-2.7-1.3-3.9-1.9-2.8-6.3-4.3-10.2-3.3M49.6 34.6c-.3.1-.5.2-.7.3-1.3.7-2.4 2-2.8 3.4-.4 1.4-.9 2.8-1.5 4.1-.6 1.5-1.9 2.6-3.4 3-1.4.2-2.8.2-4.2-.2-.3-1.2-.3-2.5-.2-3.8.1-1.4.6-2.7 1.3-3.9 1.9-2.8 6.3-4.3 10.2-3.3"/>
|
||||
<path fill="#e1e0d8" d="M56.8,68.4c-2.3,0.5-4.6,0.8-7,0.7c-2.4,0.1-4.7-0.2-7-0.7c-0.9-0.1-1.7-0.9-1.6-1.8l0,0 c-0.1-0.9,0.7-1.7,1.6-1.8c0,0,4.4,0.5,6.8,0.5s7.1-0.5,7.1-0.5c0.9,0.1,1.7,0.9,1.6,1.8l0,0C58.4,67.5,57.7,68.3,56.8,68.4z"/>
|
||||
<path fill="#472b29" d="M49.1,69.4c-2.1,0-4.3-0.2-6.4-0.7c-1.1-0.1-1.9-1-1.9-2.2c-0.1-1.1,0.8-2.1,1.9-2.1 c0.1,0,4.4,0.5,6.9,0.5c2.4,0,7-0.5,7-0.5c1.2,0.1,2,1,2,2.2c0.1,1.1-0.7,2-1.8,2.1c-2.3,0.5-4.7,0.8-7.1,0.7 C49.5,69.4,49.3,69.4,49.1,69.4z M42.7,65.1c-0.7,0-1.2,0.7-1.2,1.4c0,0.8,0.5,1.4,1.3,1.5c2.3,0.5,4.7,0.8,7,0.7 c2.3,0,4.7-0.2,6.9-0.7l0,0c0.7-0.1,1.3-0.7,1.2-1.4c0-0.8-0.5-1.4-1.3-1.5c-0.1,0-4.6,0.5-7.1,0.5 C47.1,65.6,42.9,65.1,42.7,65.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
10
web/icons/Progressive-Rock-24862.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M17,28L36.8,8.1c0.4-0.4,0.8-0.6,1.3-0.6l1.1-0.1c0.5-0.1,1-0.3,1.4-0.7L43,4.3l-0.9-0.9l-5.9,1.1c-0.5,0.1-1,0.4-1.4,0.7L14.6,25.6L17,28z"/>
|
||||
<path d="M35.3 2.1999999999999997A.6.6 0 1 0 35.3 3.4.6.6 0 1 0 35.3 2.1999999999999997zM37 1.7999999999999998A.6.6 0 1 0 37 3 .6.6 0 1 0 37 1.7999999999999998zM38.6 1.5A.6.6 0 1 0 38.6 2.7.6.6 0 1 0 38.6 1.5zM40.2 1.2000000000000002A.6.6 0 1 0 40.2 2.4.6.6 0 1 0 40.2 1.2000000000000002zM41.8 1A.6.6 0 1 0 41.8 2.2.6.6 0 1 0 41.8 1z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M22.4,34l18.4-18.4c0.4-0.4,0.8-0.6,1.3-0.6l1.1-0.1c0.5-0.1,1-0.3,1.4-0.7l2.4-2.4l-0.9-0.9L40.2,12c-0.5,0.1-1,0.4-1.4,0.7L20,31.6L22.4,34z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M26.638,29.763L26.638,29.763L22.4,34L20,31.6l4.576-4.6c-0.51,0-1.329,0-1.576,0c-1.105,0-2-0.895-2-2c0-0.365,0-1,0-1l-0.008-0.013L17,28l-2.4-2.4l4.603-4.649l-0.03-0.05C18.359,19.751,17.017,19,15.5,19c-2.316,0-4.223,1.75-4.472,3.999C11.009,23.164,11,23.331,11,23.5V24c-0.1,1.6-1,3-3,3c-0.054,0-0.085,0.018-0.122,0.031C4.027,27.348,1,30.568,1,34.5c0,0.154,0.013,0.382,0.013,0.5c-0.136,2.987,1.018,6.287,3.887,9.1C7.389,46.589,10,48,13.5,48s5.747-2.347,6.4-3c2.3-2.3,1.868-4.153,3.1-6c2-3,6-2.625,6-6.5C29,30.729,27.944,29.763,26.638,29.763z"/>
|
||||
<path d="M39.3 9.700000000000001A.6.6 0 1 0 39.3 10.9.6.6 0 1 0 39.3 9.700000000000001zM41 9.3A.6.6 0 1 0 41 10.5.6.6 0 1 0 41 9.3zM42.6 9A.6.6 0 1 0 42.6 10.2.6.6 0 1 0 42.6 9zM44.2 8.700000000000001A.6.6 0 1 0 44.2 9.9.6.6 0 1 0 44.2 8.700000000000001zM45.8 8.5A.6.6 0 1 0 45.8 9.7.6.6 0 1 0 45.8 8.5z"/>
|
||||
<path d="M16.284 34.887H17.715V39.113H16.284z" transform="rotate(-45.001 17 37)"/>
|
||||
<path d="M10.284 28.887H11.715V33.113H10.284z" transform="rotate(-45.001 11 31)"/>
|
||||
<path d="M16 42A1 1 0 1 0 16 44 1 1 0 1 0 16 42zM12 43A1 1 0 1 0 12 45 1 1 0 1 0 12 43z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
6
web/icons/Punk-40450.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path d="M35.5 27A1.5 1.5 0 1 0 35.5 30A1.5 1.5 0 1 0 35.5 27Z"/>
|
||||
<path d="M47.727,10.313c-0.312-0.329-0.805-0.408-1.203-0.192L36,15.821V3c0-0.458-0.312-0.857-0.755-0.97C34.8,1.919,34.336,2.122,34.12,2.526L28,13.891L21.88,2.526c-0.217-0.404-0.679-0.607-1.125-0.496C20.312,2.143,20,2.542,20,3v12.83L7.472,9.119C7.06,8.897,6.548,8.993,6.241,9.349C5.936,9.705,5.918,10.225,6.2,10.6l7.986,10.649L1.858,23.01c-0.451,0.064-0.801,0.425-0.852,0.877c-0.051,0.452,0.209,0.882,0.635,1.046l11.394,4.382l-9.668,7.911c-0.349,0.286-0.463,0.769-0.28,1.181C3.249,38.771,3.61,39,4,39c0.05,0,0.102-0.004,0.152-0.012l12.685-1.951C15.894,35.111,15,33.108,15,29c0-6.118,3.267-13,13-13c6.638,0,10.512,3.723,11.643,8.759c0.08-0.068,0.153-0.144,0.209-0.235l8-13C48.089,11.139,48.038,10.642,47.727,10.313z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M34,48v-3c0,0,1.529,0,4,0c1.954,0,3-1.794,3-3c0-0.909,0-7,0-7h3c0.892,0,1.16-0.895,0.812-1.521L40,28c0-6.599-4-12-12-12c-9.733,0-13,6.882-13,13c0,8.69,4,7.963,4,17v2"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M25,37L25,37c-2.2,0-4-1.8-4-4v-1c0-1.65,1.35-3,3-3l0,0c0,0,2-0.042,2,2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
27
web/icons/R2-D2-39423.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="r2d2" viewBox="0 0 48 48">
|
||||
<path fill="#9fa8da" d="M9 39H12V41H9zM11 15H37V22H11z"/>
|
||||
<path fill="#e8eaf6" d="M40 37L39 39 36 39 36 14 40 14z"/>
|
||||
<path fill="#c5cae9" d="M36 24H40V34H36zM34 15v-4c0-5.5-4.5-10-10-10S14 5.5 14 11v4H34z"/>
|
||||
<path fill="#3f51b5" d="M22 7H26V14H22z"/>
|
||||
<path fill="#000001" d="M24 8A1 1 0 1 0 24 10A1 1 0 1 0 24 8Z"/>
|
||||
<path fill="#ffff8d" d="M24.5 12A0.5 0.5 0 1 0 24.5 13A0.5 0.5 0 1 0 24.5 12Z"/>
|
||||
<path fill="#7986cb" d="M28 11A1 1 0 1 0 28 13A1 1 0 1 0 28 11Z"/>
|
||||
<path fill="#3f51b5" d="M31.119 4C29.302 2.152 26.78 1 24 1s-5.302 1.152-7.119 3H31.119zM34 11c0-.337-.018-.671-.051-1H30v4h4V11zM21.06 14v-4H14.11c-.033.329-.051.663-.051 1v3H21.06z"/>
|
||||
<path fill="#e8eaf6" d="M13 14H35V37H13z"/>
|
||||
<path fill="#3f51b5" d="M17 15H31V17H17zM17 18H31V20H17zM22 22H26V31H22zM22 33H26V37H22z"/>
|
||||
<path fill="#90a4ae" d="M30 33H32V36H30zM15 32H17V36H15z"/>
|
||||
<path fill="#cfd8dc" d="M24 23L24 23c.552 0 1 .448 1 1v1c0 .552-.448 1-1 1h0c-.552 0-1-.448-1-1v-1C23 23.448 23.448 23 24 23zM24 27L24 27c.552 0 1 .448 1 1v1c0 .552-.448 1-1 1h0c-.552 0-1-.448-1-1v-1C23 27.448 23.448 27 24 27z"/>
|
||||
<path fill="#000001" d="M24 34A1 1 0 1 0 24 36A1 1 0 1 0 24 34Z"/>
|
||||
<path fill="#9fa8da" d="M31 40L17 40 15 37 33 37z"/>
|
||||
<path fill="#3f51b5" d="M40 16H41V20H40z"/>
|
||||
<path fill="#e8eaf6" d="M8 37L9 39 12 39 12 14 8 14z"/>
|
||||
<path fill="#c5cae9" d="M8 23H12V33H8z"/>
|
||||
<path fill="#3f51b5" d="M7 16H8V20H7z"/>
|
||||
<path fill="#c5cae9" d="M8 40L7 46 8 47 13 47 14 46 14 40z"/>
|
||||
<path fill="#9fa8da" d="M13,45c-0.552,0-1-0.448-1-1v-5c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v5C14,44.552,13.552,45,13,45L13,45z"/>
|
||||
<path fill="#8d6e63" d="M13,45h-3c-0.55,0-1-0.45-1-1l0,0c0-0.55,0.45-1,1-1h3c0.55,0,1,0.45,1,1l0,0C14,44.55,13.55,45,13,45z"/>
|
||||
<path fill="#9fa8da" d="M36 39H39V41H36z"/>
|
||||
<path fill="#c5cae9" d="M40 40L41 46 40 47 35 47 34 46 34 40z"/>
|
||||
<path fill="#9fa8da" d="M35,45c0.552,0,1-0.448,1-1v-5c0-0.55-0.45-1-1-1l0,0c-0.55,0-1,0.45-1,1v5C34,44.552,34.448,45,35,45L35,45z"/>
|
||||
<path fill="#8d6e63" d="M35,45h3c0.55,0,1-0.45,1-1l0,0c0-0.55-0.45-1-1-1h-3c-0.55,0-1,0.45-1,1l0,0C34,44.55,34.45,45,35,45z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
12
web/icons/Rap-24851.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path d="M14,44l1.023,0.606C15.262,41.481,18.295,39,22,39c3.86,0,7,2.691,7,6c0,0.091-0.011,0.18-0.016,0.271L30,45l0.959-0.719C30.548,40.206,26.689,37,22,37c-4.238,0-7.793,2.621-8.743,6.134L14,44z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M36 21v5c0 0 3 .304 3 4 0 1.99-1.092 3.805-3 4M9 34c-1.908-.195-3-2.01-3-4 0-3.696 3-4 3-4v-5"/>
|
||||
<path d="M38 21.625c0 0 .77 1.116 1.263 5.447C37.706 25.794 36 26.48 36 26.48l-.3-4.855H38zM7.263 21.625c0 0-.77 1.116-1.263 5.447 1.557-1.278 3.263-.592 3.263-.592l.3-4.855H7.263z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2.066" d="M17.95,3h9.099c5.095,0,9.376,3.831,9.939,8.896L38,21H7l1.012-9.104C8.574,6.831,12.855,3,17.95,3z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2.066" d="M22,17h-8c-1.1,0-2-0.9-2-2v0c0-1.1,0.9-2,2-2h8c1.1,0,2,0.9,2,2v0C24,16.1,23.1,17,22,17z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2.066" d="M38,21c0,0,4.107,3.36,6,5c3.031,2.625,7.229,8-7.5,8"/>
|
||||
<path d="M19,46c0,0,1.719-3,3-3s3,3,3,3H19z"/>
|
||||
<path d="M25,47h-6c-0.356,0-0.687-0.19-0.865-0.499s-0.18-0.688-0.002-0.998C18.736,44.449,20.32,42,22,42s3.264,2.449,3.868,3.503c0.177,0.31,0.176,0.689-0.002,0.998S25.356,47,25,47z M20.897,45H23.1c-0.457-0.56-0.888-0.961-1.117-1.001C21.773,44.039,21.351,44.44,20.897,45z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M36,34c0,7.18-6.044,13-13.5,13S9,41.18,9,34"/>
|
||||
<path d="M29,24.007c-0.643,0-1.256-0.014-1.821,0h-9.353c-0.566-0.014-1.181,0-1.826,0c-2.761,0-5-0.279-5,2.664C11,29.615,13.239,32,16,32c2.53,0,4.581-2.016,4.912-4.617c0.006-0.082,0.08-0.383,0.08-0.383c0.207-0.581,0.756-1,1.408-1h0.2c0.652,0,1.202,0.419,1.408,1c0,0,0.074,0.301,0.08,0.383C24.419,29.984,26.47,32,29,32c2.761,0,5-2.385,5-5.329C34,23.727,31.761,24.007,29,24.007z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
10
web/icons/Reggae-24843.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path d="M31.908 28C31.964 27.843 32 27.676 32 27.5c0-.828-.672-1.5-1.5-1.5S29 26.672 29 27.5c0 .176.036.343.092.5H31.908zM21.908 28C21.964 27.843 22 27.676 22 27.5c0-.828-.672-1.5-1.5-1.5S19 26.672 19 27.5c0 .176.036.343.092.5H21.908z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M36.027 23.729C36.653 26.565 37 29.701 37 33c0 4.398-.93 8.506-2 12M38.745 25.407C40.12 27.407 41 32.744 41 36c0 5.029-.771 8.602-2.03 12M44.705 45C44.897 43.584 45 42.071 45 40.5c0-8.008-2.25-14.5-4-14.5"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M41,26c-3.908,0-7.25-6-15.5-6S13.908,26,10,26c-2.792,0-4-2.131-4-6.5C6,9.835,14.73,2,25.5,2S45,9.835,45,19.5C45,23.869,43.792,26,41,26c-3.908,0-7.25-6-15.5-6S13.908,26,10,26"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M25.5,20c-8.25,0-11.592,6-15.5,6c-2.792,0-4-2.131-4-6.5c0-1.391,0.2-2.737,0.541-4.034C10.54,9.776,17.534,6,25.5,6s14.96,3.776,18.959,9.466C44.8,16.763,45,18.109,45,19.5c0,4.369-1.208,6.5-4,6.5C37.092,26,33.75,20,25.5,20z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M25.5,12c8.086,0,15.216,3.563,19.444,8.987C44.682,24.351,43.453,26,41,26c-3.908,0-7.25-6-15.5-6S13.908,26,10,26c-2.453,0-3.682-1.649-3.944-5.013C10.284,15.563,17.414,12,25.5,12s15.216,3.563,19.444,8.987C44.682,24.351,43.453,26,41,26c-3.908,0-7.25-6-15.5-6S13.908,26,10,26c-2.453,0-3.682-1.649-3.944-5.013C10.284,15.563,17.414,12,25.5,12z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M25.5 20c-4.493 0-7.526 1.779-9.992 3.4C15.181 24.85 15 26.394 15 28c0 8.284 4.701 15 10.5 15S36 36.284 36 28c0-1.606-.181-3.15-.508-4.6C33.026 21.779 29.993 20 25.5 20zM27 35L30 36M24 35L21 36"/>
|
||||
<path d="M32.409,39.793c-0.116-0.21-0.304-0.35-0.512-0.432L32,39l-0.376,0.301c-0.193-0.018-0.392,0.001-0.574,0.102c-0.729,0.403-1.563,0.718-2.346,0.957c-0.767,0.235-1.615-0.022-2.106-0.657C26.267,39.274,25.894,39,25.5,39s-0.767,0.274-1.098,0.703c-0.49,0.635-1.338,0.892-2.106,0.657c-0.783-0.24-1.617-0.554-2.346-0.957c-0.183-0.101-0.381-0.12-0.574-0.102L19,39l0.103,0.361c-0.208,0.082-0.395,0.221-0.512,0.432c-0.268,0.483-0.092,1.092,0.391,1.359c0.239,0.132,0.487,0.253,0.738,0.369l1.391,2.123C21.666,44.49,22.609,45,23.621,45h3.758c1.011,0,1.955-0.51,2.509-1.356l1.391-2.123c0.251-0.116,0.499-0.237,0.738-0.369C32.501,40.885,32.676,40.276,32.409,39.793z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M16 45c-1.07-3.494-2-7.602-2-12 0-3.299.347-6.435.973-9.271M12.03 48C10.771 44.602 10 41.029 10 36c0-3.256.88-8.593 2.255-10.593M10 26c-1.75 0-4 6.492-4 14.5 0 1.571.103 3.084.295 4.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
7
web/icons/Rock-Music-11007.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M21.4,31L42.8,9.6c0.4-0.4,0.8-0.6,1.3-0.6l1.1-0.1c0.5-0.1,1-0.3,1.4-0.7L49,5.8l-0.9-0.9L42.2,6c-0.5,0.1-1,0.4-1.4,0.7L19,28.6L21.4,31z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M23.1,24.2c0.2-1.3,0.1-2.5-1.1-3.7c-1.7-1.7-4.7-1.8-6.6,0l0,0c-1.1,1.1-1.6,2.4-1.7,3.5c-0.1,1.6-1.5,2.8-3.1,2.7c-2.1-0.1-4.7,0.3-6.7,2.3l0,0C0,33-0.3,39,4.9,44.1c5.2,5.2,11.1,4.8,15,0.9c2.3-2.3,2.3-4.4,2.6-6.6c0.5-4.1,6.5-2.3,6.5-6.8c0-1.9-4.6,1.2-5.6-2.6"/>
|
||||
<path d="M13.4 32.1H19.6V34.2H13.4z" transform="rotate(-134.999 16.537 33.103)"/>
|
||||
<path d="M11.5 34.1H13.6V40.300000000000004H11.5z" transform="rotate(134.999 12.48 37.159)"/>
|
||||
<path d="M18 41A1 1 0 1 0 18 43 1 1 0 1 0 18 41zM15 43A1 1 0 1 0 15 45 1 1 0 1 0 15 43zM11 43A1 1 0 1 0 11 45 1 1 0 1 0 11 43zM41.3 3.6999999999999997A.6.6 0 1 0 41.3 4.8999999999999995.6.6 0 1 0 41.3 3.6999999999999997zM43 3.3A.6.6 0 1 0 43 4.5.6.6 0 1 0 43 3.3zM44.6 3A.6.6 0 1 0 44.6 4.2.6.6 0 1 0 44.6 3zM46.2 2.6999999999999997A.6.6 0 1 0 46.2 3.9.6.6 0 1 0 46.2 2.6999999999999997zM47.8 2.5A.6.6 0 1 0 47.8 3.7.6.6 0 1 0 47.8 2.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
web/icons/Rock-Music-11076.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path d="M21.6 31.3c-.1 0-.3-.1-.4-.1l-2.4-2.4c-.2-.2-.2-.5 0-.7l5.3-5.3c-.1-.9-.5-1.9-1.4-2.9-1-1-2.5-1.6-4-1.6-1.5 0-2.9.6-4 1.6-1.1 1.1-1.9 2.6-2 4.1-.1 1-.9 1.8-1.9 1.8l-.1 0c-.2 0-.4 0-.6 0-2.8 0-5.1.9-6.8 2.6C-1.4 33-1 39.7 4.2 44.8 6.9 47.5 10 49 13.1 49c2.7 0 5.4-1.2 7.5-3.3 2.3-2.3 2.6-4.5 2.8-6.4 0-.2.1-.5.1-.7.2-1.3 1.1-1.7 2.6-2.4 1.6-.7 3.9-1.5 3.9-4.5 0-.4-.1-1.7-2-1.7-.3 0-.6 0-.9.1-.3 0-.6.1-1 .1-1.1 0-1.5-.3-1.8-1.2l0-.1L22 31.1C21.9 31.2 21.8 31.3 21.6 31.3zM11 45c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1S11.6 45 11 45zM9.5 35.7l1.5-1.5 4.4 4.4-1.5 1.5L9.5 35.7zM15 45c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1S15.6 45 15 45zM18 43c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1S18.6 43 18 43zM18 36l-4.4-4.4 1.5-1.5 4.4 4.4L18 36zM41.3 3.6999999999999997A.6.6 0 1 0 41.3 4.8999999999999995.6.6 0 1 0 41.3 3.6999999999999997zM43 3.3A.6.6 0 1 0 43 4.5.6.6 0 1 0 43 3.3zM44.6 3A.6.6 0 1 0 44.6 4.2.6.6 0 1 0 44.6 3zM46.2 2.6999999999999997A.6.6 0 1 0 46.2 3.9.6.6 0 1 0 46.2 2.6999999999999997zM47.8 2.5A.6.6 0 1 0 47.8 3.7.6.6 0 1 0 47.8 2.5z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M21.4,31L42.8,9.6C43.2,9.2,43.6,9,44.1,9l1.1-0.1c0.5-0.1,1-0.3,1.4-0.7L49,5.8l-0.9-0.9L42.2,6c-0.5,0.1-1,0.4-1.4,0.7L19,28.6L21.4,31z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
5
web/icons/Rock-Music-17785.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M28,5h-2.553c-0.284,0-0.555,0.121-0.745,0.333L13.803,17.496"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M15.266,15.891c-2.672-2.672-0.345-5.304-0.588-5.547C14.435,10.101,11,10.122,11,12.872C11,14.014,10.135,15,8.818,15c-0.812,0-2.087,0.357-3.198,1.468l0,0c-2.166,2.221-2.332,5.554,0.555,8.386c2.888,2.888,6.164,2.666,8.33,0.5c1.277-1.277,1.277-2.444,1.444-3.665C16.228,19.411,20,20.875,20,17.4c0-0.448-1.415,1.153-2.656-0.087"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="1.4" d="M10.514 17.986L13.014 20.486M8.25 20.25L10.75 22.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
5
web/icons/Scottish-Thistle-108212.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M25.045 12.818c0 0-.074.694-.369 1.233-.295.538-.295.668-.222 1.13-.812 0-.591-.591-1.773-.591.739 1.387 1.256 3.545.591 3.545-.591 0-.591-1.182-.591-1.182S21.222 18 21.5 20.5c.074.665-.443.591-.591.591-.443 0-.569-.648 0-1.182 0 0-1.56-.372-2.053 2.682 0 0-.114 1.033-1.973 1.491 0 0-1.603.343-1.883 1.068V27c0 0 .591-1.927 2.955-.616.8.443 2.29 1.105 4.727.025 0 0-1.33-.334-1.182-1.028.233-1.096 2.031.502 3.25-.771.517-.539.665-.925 2.068-1.156 0 0-.295-.591-1.182-.591-.21 0-.886 0-.443-.591.471-.628 2.364-1.285 2.807-4.136 0 0-.369.591-1.773.591 0 0 .591-.36.591-1.593 0-1.335.591-1.952.591-1.952s-.591-.18-1.182.591C26.227 15.773 26.597 13.82 25.045 12.818zM4.955 12.818c0 0 .074.694.369 1.233.295.538.295.668.222 1.13.812 0 .591-.591 1.773-.591-.739 1.387-1.256 3.545-.591 3.545.591 0 .591-1.182.591-1.182S8.778 18 8.5 20.5c-.074.665.443.591.591.591.443 0 .569-.648 0-1.182 0 0 1.56-.372 2.053 2.682 0 0 .114 1.033 1.973 1.491 0 0 1.603.343 1.883 1.068V27c0 0-.591-1.927-2.955-.616-.8.443-2.29 1.105-4.727.025 0 0 1.33-.334 1.182-1.028-.233-1.096-2.031.502-3.25-.771-.517-.54-.665-.925-2.068-1.156 0 0 .295-.591 1.182-.591.21 0 .886 0 .443-.591C4.336 21.645 2.443 20.988 2 18.136c0 0 .369.591 1.773.591 0 0-.591-.36-.591-1.593 0-1.335-.591-1.952-.591-1.952s.591-.18 1.182.591C3.773 15.773 3.403 13.82 4.955 12.818z"/>
|
||||
<path d="M15 8A6 6 0 1 0 15 20A6 6 0 1 0 15 8Z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M15 27L15 3M16 10c0-3.207 2-6 2-6M17 12c0-3.666 3-6 3-6M14 10c0-3.207-2-6-2-6M13 12c0-3.666-3-6-3-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
web/icons/Sleeping-in Bed-14385.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M8,21v-7.5C8,12.7,7.3,12,6.5,12h0C5.7,12,5,12.7,5,13.5V27h3v-2h16v2h3v-6H8z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M14,21h13v-4c0-1.1-0.9-2-2-2H14V21z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M11 15A3 3 0 1 0 11 21 3 3 0 1 0 11 15zM22 5L27 5 27 6 23 9 23 10 28 10M15 7L19 7 19 7.2 16 10.7 16 11 20 11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
3
web/icons/Sydney-Opera House-59090.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path d="M49 38c-4.118 0-5.011-2.209-5.056-2.33-.014-.04-.043-.068-.061-.104-.029-.059-.058-.115-.099-.168-.044-.058-.095-.104-.15-.15-.029-.025-.047-.058-.079-.08-.017-.012-.038-.012-.056-.023-.064-.037-.131-.06-.201-.083-.056-.018-.109-.038-.166-.045-.062-.008-.123-.003-.187.001-.069.004-.136.008-.203.026-.02.005-.039.001-.059.008-.039.013-.067.042-.103.059-.064.03-.125.06-.181.104-.055.042-.099.091-.144.144-.027.032-.063.051-.087.087C42.151 35.471 40.425 38 37 38c-3.382 0-5.095-2.448-5.17-2.559-.047-.07-.112-.116-.172-.17-.037-.033-.061-.075-.103-.104-.001-.001-.002 0-.003-.001-.11-.073-.233-.123-.361-.148-.077-.015-.153-.004-.23-.001-.051.002-.102-.009-.153.001-.129.025-.252.075-.363.149 0 0 0 0 0 0-.027.018-.041.046-.066.066-.077.062-.154.125-.211.211 0 0-.002.003-.002.003s0 .001-.001.001C30.12 35.516 28.397 38 25 38c-3.382 0-5.095-2.448-5.17-2.559-.046-.069-.11-.114-.169-.167-.037-.034-.062-.077-.106-.106-.001-.001-.002-.001-.003-.001-.11-.073-.233-.123-.361-.148-.077-.015-.153-.004-.23-.001-.051.002-.102-.009-.153.001-.129.025-.252.075-.363.149 0 0 0 0 0 0-.013.008-.019.022-.031.031-.092.068-.18.146-.247.247 0 0-.002.003-.002.003s0 .001-.001.001C18.12 35.516 16.397 38 13 38c-3.382 0-5.095-2.448-5.17-2.559-.048-.072-.115-.12-.177-.175-.035-.031-.058-.071-.098-.098-.001 0-.002 0-.002-.001-.11-.073-.233-.123-.361-.148-.077-.015-.153-.004-.23-.001C6.91 35.02 6.86 35.009 6.809 35.019c-.129.025-.252.075-.362.149 0 0-.001 0-.001 0-.041.027-.064.068-.1.1-.062.056-.129.105-.178.177 0 0-.002.003-.002.003s0 .001-.001.001C6.12 35.516 4.397 38 1 38c-.552 0-1 .448-1 1s.448 1 1 1c2.98 0 4.969-1.457 6-2.478C8.031 38.543 10.02 40 13 40s4.969-1.457 6-2.478C20.031 38.543 22.02 40 25 40s4.969-1.457 6-2.478C32.031 38.543 34.02 40 37 40c2.857 0 4.806-1.341 5.87-2.351C43.773 38.705 45.574 40 49 40c.552 0 1-.448 1-1S49.552 38 49 38zM1 35c2.98 0 4.969-1.457 6-2.478C8.031 33.543 10.02 35 13 35s4.969-1.457 6-2.478C20.031 33.543 22.02 35 25 35s4.969-1.457 6-2.478C32.031 33.543 34.02 35 37 35c2.857 0 4.806-1.341 5.87-2.351C43.773 33.705 45.574 35 49 35c.552 0 1-.448 1-1 0-.195-.07-.366-.167-.52l.015-.009-5-8C44.665 25.178 44.345 25 44 25h-2.974c.26-5.906 2.74-9.237 2.765-9.269.216-.279.269-.651.139-.979-.129-.328-.422-.563-.77-.62-.297-.047-6.582-.997-10.608 2.933C27.985 7.121 16.123 7 16 7c-.353 0-.68.186-.86.49-.18.303-.187.68-.018.989.02.037 1.029 1.916 2.039 5.009-2.944-1.471-5.945-1.663-6.616-1.351-.353.165-.599.539-.599.928 0 .181.05.36.143.515.017.029.943 1.607 1.794 4.14-2.51-1.021-5.056-.665-5.198-.644C6.33 17.129 6.03 17.369 5.9 17.705s-.069.715.158.994C6.082 18.727 8.22 21.393 8.577 25H6c-.345 0-.665.178-.848.47l-5 8 .015.009C.07 33.634 0 33.805 0 34 0 34.552.448 35 1 35zM32 20.329L37.338 25H26.662L32 20.329zM41.289 16.006c-.802 1.526-1.874 4.201-2.19 7.877l-5.603-4.903C35.743 16.388 39.307 15.996 41.289 16.006zM31.007 18.54L23.624 25h-2.432c-.186-7.311-2.35-13.179-3.544-15.878C20.905 9.522 28.055 11.238 31.007 18.54zM17.981 16.343c.623 2.482 1.116 5.43 1.204 8.657h-3.933c-.189-4.859-1.678-8.825-2.643-10.893C13.975 14.316 16.092 14.901 17.981 16.343zM8.668 19.028c1.181.087 2.755.427 3.989 1.467.307 1.368.536 2.883.605 4.504h-2.675C10.38 22.489 9.44 20.377 8.668 19.028z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
6
web/icons/Tango-25015.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M27.5 4A3.5 3.5 0 1 0 27.5 11 3.5 3.5 0 1 0 27.5 4zM18.5 7A3.5 3.5 0 1 0 18.5 14 3.5 3.5 0 1 0 18.5 7z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M39.478,8.348c0.742-0.812,0.686-2.083-0.126-2.826c-0.812-0.742-2.083-0.686-2.826,0.126l-3.799,4.239l-13.122,6.729c-1.454,0.745-2.044,2.517-1.327,3.985l2.45,5.019l-4.875,5.729c-0.096,0.086-0.139,0.137-0.126,0.148L7.593,41c-0.891,1.047-0.763,2.633,0.284,3.524v0c1.047,0.891,2.633,0.763,3.524-0.284l9.614-11.241c5.378-0.008,9.748-4.263,9.964-9.59L31,23.416l3.127,8.913c0.455,1.297,1.889,1.987,3.187,1.531c1.297-0.455,1.987-1.889,1.531-3.187l-3.147-8.97c-0.455-1.297-1.889-1.987-3.187-1.531l-1.798,0.505c-0.008,0-0.018,0-0.042,0.012l-2.729,0.766L26.407,18l7.749-4.195l0-0.003c0.282-0.107,0.545-0.272,0.762-0.508L39.478,8.348z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M21.015 32.999l-2.823 3.3-2.859 4.952c-.688 1.191-.276 2.728.915 3.415 1.191.688 2.728.276 3.415-.915L27.06 30.94C25.383 32.225 23.292 32.996 21.015 32.999zM34.126 32.328L31 23.416l-.021-.006c-.081 1.989-.742 3.829-1.818 5.354l.842 13.879c.078 1.373 1.265 2.432 2.638 2.354 1.373-.078 2.432-1.265 2.354-2.638l-.571-9.46C34.305 32.723 34.2 32.537 34.126 32.328zM39.996 6.956c.01.497-.158.998-.518 1.391l-4.561 4.947c-.216.236-.48.401-.762.508l0 .003L26.407 18l1.535 3.454 2.729-.766c.024-.011.034-.012.042-.012l1.798-.505c.497-.174 1.014-.178 1.489-.046V18l6-3V7C40 6.985 39.996 6.971 39.996 6.956z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M30.541,19.997C30.839,20.945,31,21.954,31,23c0,5.523-4.477,10-10,10c-2.252,0-4.33-0.744-6.001-2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
5
web/icons/Theatre-Mask-111172.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<path d="M64.9,98c-0.2,0.2-0.4,0.3-0.7,0.5l-0.1-0.1c-3.7-3-7-6.4-9.9-10l-12.6,4.6c-2.3-6.3-0.1-13.1,4.9-17l0,0c-3.8-8.2-5.9-17.3-5.9-26.7v-16c0.3-0.1,0.5-0.2,0.8-0.2c1.6-0.4,2.5-2.1,2.1-3.7c-0.4-1.6-2.1-2.5-3.7-2.1C26.2,30.9,14.2,38.6,5.1,49.4C4.2,50.5,4,52,4.5,53.3l6.9,19c5.8,16,18.4,28.4,34.6,33.9c2.6,0.9,5.3,1.3,8,1.3c5.1,0,10.2-1.6,14.5-4.7c1.3-1,1.6-2.8,0.7-4.2C68.1,97.4,66.2,97,64.9,98z M34.5,61.5L26,64.6c-1.6,0.6-3.3-0.2-3.8-1.8c-0.6-1.6,0.2-3.3,1.8-3.8l8.5-3.1c1.6-0.6,3.3,0.2,3.8,1.8S36,60.9,34.5,61.5z"/>
|
||||
<path d="M118.8,25.9l-0.5-0.3c-21.2-12.2-47.5-12.2-68.8,0c0,0,0,0,0,0c-1.2,0.7-2,2-2,3.4v20.3c0,17.1,7.6,33,20.9,43.7c4.5,3.6,10,5.4,15.5,5.4s11-1.8,15.5-5.4c13.3-10.7,20.9-26.6,20.9-43.7V28.5C120.3,27.4,119.8,26.4,118.8,25.9z M84,79v5.9c0,3.2,1.8,5.7,4.2,7c-5.5,1.3-11.4,0.1-15.9-3.6c-11.8-9.6-18.6-23.8-18.6-39v-19c9.4-5.2,19.9-7.8,30.3-7.8V64h15C99,72.3,92.3,79,84,79z M103.1,47h-9c-1.7,0-3-1.3-3-3s1.3-3,3-3h9c1.7,0,3,1.3,3,3S104.7,47,103.1,47z"/>
|
||||
<path d="M84 79l0-15H69C69 72.3 75.7 79 84 79zM76.1 44c0-1.7-1.3-3-3-3h-9c-1.7 0-3 1.3-3 3s1.3 3 3 3h9C74.7 47 76.1 45.7 76.1 44z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
8
web/icons/TripAdvisor-44407.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M1 6.75C1.517 6.09 2.372 5.482 3.405 5H0C.414 5.483.924 6.3 1 6.75zM16 5h-3.527C13.644 5.539 14.521 6.203 15 6.75 15.278 6.006 15.556 5.591 16 5z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" d="M4 5.5A3.5 3.5 0 1 0 4 12.5A3.5 3.5 0 1 0 4 5.5Z"/>
|
||||
<path d="M4 8A1 1 0 1 0 4 10A1 1 0 1 0 4 8Z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" d="M12 5.5A3.5 3.5 0 1 0 12 12.5A3.5 3.5 0 1 0 12 5.5Z"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" d="M12.752,5.565C11.566,4.294,9.876,3.5,8,3.5c-1.81,0-3.447,0.74-4.625,1.933"/>
|
||||
<path d="M12 8A1 1 0 1 0 12 10 1 1 0 1 0 12 8zM7 11L8 13 9 11 8.5 9 7.5 9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 720 B |
5
web/icons/Trumpet-17823.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M12 18L24.154 18 28 23 29 23 29 10 28 10 24.154 15 12.154 15"/>
|
||||
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M21.417 15H12c-2.209 0-4 1.791-4 4v0c0 2.209 1.791 4 4 4h6.5c1.381 0 2.5-1.119 2.5-2.5v0c0-1.381-1.119-2.5-2.5-2.5M3 19L3 15M12 15L12 12M15 15L15 12M18 15L18 12M12 25L12 20M15 25L15 20M18 25L18 20"/>
|
||||
<path d="M10 19h3v-2h-1C10.897 17 10 17.897 10 19zM6.812 16H3v2h3.09C6.212 17.282 6.459 16.608 6.812 16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
10
web/icons/US-Capitol-104805.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
|
||||
<path d="M9 11c0-3 2.7-6.373 6-6.373S21 8 21 11H9zM13.5 3c0-.7.7-1.5 1.5-1.5s1.5.8 1.5 1.5H13.5z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M8 14L22 14"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M9 14L9 20M13 14L13 20M17 14L17 20M21 14L21 20"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M6 20L24 20"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M7 21L23 21"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M6 26L24 26"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M7 20L7 27M11 20L11 27M15 20L15 27M19 20L19 27M23 20L23 27"/>
|
||||
<path d="M15.322,2h-0.644C14.304,2,14,2.304,14,2.678V6h2V2.678C16,2.304,15.696,2,15.322,2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
6
web/icons/Violin-3421.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23.2,3.1c-0.4,0.4-1.1,0.4-1.5,0l-0.7-0.7c-0.4-0.4-0.4-1.1,0-1.5c0.4-0.4,1.2,0.3,2.3-0.8L24,0.8 C22.9,1.8,23.6,2.7,23.2,3.1z"/>
|
||||
<path d="M13 12.8L11.2 11 22.5.8 23.2 1.5zM6.2 15.9L8 17.7 3.2 21.9 2 20.7z"/>
|
||||
<path d="M12.1,7.5c1.1,0,2.2,1,2.6,1.5l0.1,0.1l0.1,0.1c0.4,0.3,2.4,2.1,1.1,3.9c-0.2,0.2-0.4,0.5-0.6,0.9 C15.2,14,14.9,14,14.8,14c-1.4,0-2.5,1-3.2,1.7c-2.2,2.2-1.8,3.9-1.1,4.9c-0.2,0.3-0.5,0.5-0.7,0.7c-0.8,0.8-1.6,1.2-2.5,1.2 c-1.2,0-2.5-0.7-3.7-2l0,0l0,0c-1.3-1.2-2-2.5-2-3.7c0-0.9,0.4-1.7,1.2-2.5c0.2-0.2,0.4-0.4,0.7-0.7C4,14,4.6,14.1,5.1,14.1 c1.4,0,2.4-0.9,3.2-1.7c1.5-1.5,1.8-2.8,1.6-3.8c0.4-0.2,0.7-0.5,0.9-0.6C11.2,7.7,11.7,7.5,12.1,7.5 M12.1,6 c-0.7,0-1.4,0.2-2.2,0.8C9.5,7.1,8.8,7.5,8,8.1c1,1.1,0.3,2.2-0.8,3.3c-0.7,0.7-1.4,1.2-2.1,1.2c-0.4,0-0.8-0.2-1.2-0.6 c-0.2-0.2-0.3-0.4-0.4-0.6c-0.7,0.6-1.3,1.1-1.9,1.7c-2.6,2.6-1.8,5.8,0.9,8.4C4,23.1,5.7,24,7.3,24c1.2,0,2.4-0.5,3.5-1.6 c0.6-0.6,1.1-1.2,1.7-1.9c-0.2-0.1-0.4-0.2-0.6-0.4c-1.1-1.1-0.4-2.2,0.7-3.4c0.7-0.7,1.4-1.2,2.1-1.2c0.4,0,0.8,0.1,1.1,0.5 c0.6-0.8,1-1.5,1.4-1.9c2.2-3-1-5.7-1.4-6C15.7,7.8,14.1,6,12.1,6L12.1,6z"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" d="M17.5 3.7L20.3 6.5M18.9 2.3L21.7 5.1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
10
web/icons/Year-of Dragon-4537.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="yoDragon" viewBox="0 0 50 50">
|
||||
<path d="M36.9 33c.6 0 4.3 0 4.3 0s-2.7 1.9-3.7 2.3L36.9 33zM37.1 13c1.4-.4 1.4-3.9 1.4-3.9s3 4.1 3.1 7.4L37.1 13zM41.8 17.1c1.2-.2 1.5-3.1 1.5-3.1s2.1 3.7 1.8 6.4L41.8 17.1zM44.3 28c-.2.5-2 4.6-2 4.6s3-2.3 3.9-3.1L44.3 28z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M22.4 28.6c3 3.5 5.6 9 5.6 20.4M9 49c0 0 2-3 2-8 0-7.9-5-11-5-18 0-5.6 2.9-10.8 8.2-12.7"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M42.6,22.1c1.7,1.2,2.6,2.4,2.6,2.4"/>
|
||||
<path d="M32.7,14.4c0,0,4,2.9,5,4.2c0,0,0.2,1-0.2,1.1c-0.6,0.2-3.1,0.5-4-0.2l0.7-2.4l-2.2,2c-1.2-0.4-2.2-1.1-2.2-1.1S29.5,15.4,32.7,14.4z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M6.5 6.1c1.7 4-1.2 7.3-5.3 7.6 2.3 2.8 3 9.3-.2 12.1 2.9 2.3 4.2 5.3 1 10 4.4 1.1 5.1 6.4 2 9.3 3.9 1.3 5.1 3.5 5.1 3.5M36.1 13.2c0 0-4.1-3.6-4.3-8.9-1.5 1.2-6.4 2.6-7.7-3-1.1 2.7-5.8 6-9.8.5-.2 1.1-1.7 1.6-1.7 1.6M38.1 37c0 0-1.1 1-2.8 1.5-1.7.4-4.5.8-4.5.8l2.5-3.7L26.5 29c-2.1 1.1-12-3.3-7.4-11.5M37 24.2c-.6-.1-1.2 0-1.8.4-.5.3-.8.7-.9 1.2"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M37.2,18.8C28,8.4,7.5,14.8,3.6,1C9.5,10.2,23.8,6.1,37,13.7c10,5.8,13.7,14.9,11.2,16.4"/>
|
||||
<path d="M36.9 24.6l-.1 1.5 1.6-.9-.1 1.6 1.7-.5L40 27.6l1.2-.6.1 1.5 1.6-.7c0 0-.4 1.4-.2 1.3.2-.1 1.7-.5 1.7-.5l.1 1.3 1.3-.3.1 1.3 1.3-.6v1.2l1.1-.4-.1-2.2c0 0-2.2-1.2-4.2-2.3s-5.2-3.2-6.6-3.4C36.5 23.1 36.9 24.6 36.9 24.6zM38.6 36.3l1-1.1-1.9-.5 1.1-1-1.7-.6.9-1-1.3-.3.9-1.2L36 30.1c0 0 1.1-.9 1-.9-.1-.1-1.6-.6-1.6-.6l.7-1-1.2-.5.7-1.1-1.8-1.4c0 0-.6.8-.6 2.1 0 1.1 1.4 4 1.4 4l2.4 6L38.6 36.3z"/>
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M23.7 2.7c0 0 0 1.6.1 2.9M14.4 3.3L14.3 4.5M31.4 5c0 0-1 1.5-1.4 2.4M2.2 14.3L4.3 15M1.4 25.7l2.1-.3M2.8 35.5c0 0 1.5-.3 3-1.1M4.8 44.9c0 0 1.3-.4 3.1-1.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
5
web/icons/Year-of Rabbit-6313.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="yoRabbit" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M12,22c0-6.4,4.6,0.1,6.3-5.5c0,0,0.7-0.2,0.7-0.8c0-0.4-1.4-4.7-7.6-4.7c0-5.4-3.7-7-4.8-7C6.2,4,6,4.1,6,4.5c0,3.3,1.1,5.4,3,7.1C6.5,12.3,7.5,18,5,18"/>
|
||||
<path fill="none" stroke="#000" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M9,5.1C9,5.1,8.8,3,9.9,3c0.5,0,4.1,1.9,4.1,8.3"/>
|
||||
<path d="M14 13.299999999999999A0.8 0.8 0 1 0 14 14.9A0.8 0.8 0 1 0 14 13.299999999999999Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |