This commit is contained in:
Lordmau5 2024-03-13 11:26:46 +01:00
parent 6b81eea6b4
commit 4a5fe29bf5
858 changed files with 82920 additions and 1 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git/
.cache/
node_modules/
dashboard/
extension/
graphics/
/streamdeck-plugin
.gitignore
Dockerfile
.dockerignore

13
.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.eslintrc.js
.eslintrc.*.js
webpack.config.mjs
vetur.config.js
dashboard/**/*
extension/**/*
graphics/**/*
node_modules/**/*
player-templates/**/*
schemas/**/*
streamdeck-plugin/**/*
src/types/schemas/**/*
src/types/augment-window.d.ts

70
.eslintrc.browser.js Normal file
View File

@ -0,0 +1,70 @@
const path = require('path');
module.exports = {
root: true,
env: {
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
project: path.join(__dirname, 'tsconfig.browser.json'),
extraFileExtensions: ['.vue'],
ecmaVersion: 2020,
},
globals: {
nodecg: 'readonly',
NodeCG: 'readonly',
},
plugins: [
'@typescript-eslint',
],
extends: [
'plugin:vue/essential',
'airbnb-base',
'airbnb-typescript/base',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
settings: {
'import/resolver': {
typescript: {
// This is needed to properly resolve paths.
project: path.join(__dirname, 'tsconfig.browser.json'),
},
webpack: {
config: path.join(__dirname, 'webpack.config.mjs'),
},
},
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
},
rules: {
// Everything is compiled for the browser so dev dependencies are fine.
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
// max-len set to ignore "import" lines (as they usually get long and messy).
'max-len': ['error', { code: 100, ignorePattern: '^import\\s.+\\sfrom\\s.+;' }],
// I mainly have this off as it ruins auto import sorting in VSCode.
'object-curly-newline': 'off',
'@typescript-eslint/lines-between-class-members': 'off',
'vue/html-self-closing': ['error'],
'class-methods-use-this': 'off',
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: [
'state', // for vuex state
'acc', // for reduce accumulators
'e', // for e.returnvalue
],
}],
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
}],
'no-restricted-syntax': 'off',
'vue/multi-word-component-names': 'off', // Check about this once things are all using decorators!
}
};

60
.eslintrc.extension.js Normal file
View File

@ -0,0 +1,60 @@
const path = require('path');
module.exports = {
root: true,
env: {
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: path.join(__dirname, 'tsconfig.extension.json'),
},
plugins: [
'@typescript-eslint',
],
extends: [
'airbnb-base',
'airbnb-typescript/base',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
settings: {
'import/resolver': {
typescript: {
// This is needed to properly resolve paths.
project: path.join(__dirname, 'tsconfig.extension.json'),
},
},
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
},
rules: {
'@typescript-eslint/lines-between-class-members': 'off',
// max-len set to ignore "import" lines (as they usually get long and messy).
'max-len': ['error', { code: 100, ignorePattern: '^import\\s.+\\sfrom\\s.+;' }],
// I mainly have this off as it ruins auto import sorting in VSCode.
'object-curly-newline': 'off',
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
}],
'no-restricted-syntax': 'off',
'no-await-in-loop': 'off',
},
// Overrides for types.
overrides: [{
files: ['**/*.d.ts'],
rules: {
// @typescript-eslint/no-unused-vars does not work with type definitions
'@typescript-eslint/no-unused-vars': 'off',
// Sometimes eslint complains about this for types (usually when using namespaces).
'import/prefer-default-export': 'off',
// Types are only used for development (usually!) so dev dependencies are fine.
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
}
}],
};

4
.eslintrc.js Normal file
View File

@ -0,0 +1,4 @@
// This is here just to make sure ESLint doesn't check NodeCG's own configuration.
module.exports = {
root: true,
};

136
.gitignore vendored Normal file
View File

@ -0,0 +1,136 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Built files
/dashboard/
/extension/
/graphics/
streamdeck-plugin/com.esamarathon.streamdeck.sdPlugin
streamdeck-plugin/DistributionTool.exe
*.psd
/boxart/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "shared"]
path = shared
url = https://github.com/esamarathon/esa-layouts-shared

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"eslint.workingDirectories": [
"shared",
"streamdeck-plugin"
],
"typescript.tsdk": "node_modules\\typescript\\lib"
}

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# NEEDS SOME IMPROVEMENTS, MAINLY TO DO WITH THE
# SPEEDCONTROL BRANCH USED AND CONFIG SETTINGS
FROM node:10
WORKDIR /home/node/app
RUN chown -R node:node /home/node/app
# Install some packages to install NodeCG.
RUN npm install bower -g && npm install nodecg-cli -g
USER node
RUN nodecg setup
# Install latest nodecg-speedcontrol.
RUN nodecg install speedcontrol/nodecg-speedcontrol
# Copy over this bundle's files and fully build it.
WORKDIR /home/node/app/bundles/esa-layouts
USER root
RUN chown -R node:node /home/node/app/bundles/esa-layouts
USER node
COPY --chown=node:node package*.json ./
RUN npm install
COPY --chown=node:node . .
RUN npm run build
# Run NodeCG.
WORKDIR /home/node/app
EXPOSE 9090
CMD [ "nodecg", "start" ]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 European Speedrunner Assembly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# esa-layouts
> The on-screen graphics used during European Speedrunner Assembly's "marathon" events.
*This is a bundle for [NodeCG](https://nodecg.dev); if you do not understand what that is, we advise you read their website first for more information.*
***This documentation isn't fully complete and may have errors, but intends to be as correct as possible.***
This is a [NodeCG](https://nodecg.dev) v1.8.1 bundle. You will need to have NodeCG v1.8.1 or above installed to run it. It also requires you to install the [nodecg-speedcontrol](https://github.com/speedcontrol/nodecg-speedcontrol) bundle (of which you may also need to install the latest changes instead of the most stable release).
## Installation
You will need [Node.js](https://nodejs.org) (16.x LTS tested) and [git](https://git-scm.com/) installed to install NodeCG, then see the [NodeCG documentation](https://www.nodecg.dev/docs/installing) on how to install that. I also suggest installing `nodecg-cli`; information on that is also on the documentation just linked (**the guide below will assume you have done this!**). You may also need to install the appropriate build tools for whichever platform you are running on; for example if you are on Windows you can either install it while installing Node.js, or using [windows-build-tools](https://github.com/felixrieseberg/windows-build-tools).
Next, clone the `build` branch of this repository into the NodeCG `bundles` folder and install the dependencies:
> ```
> cd bundles
> git clone https://github.com/esamarathon/esa-layouts.git --branch build
> cd esa-layouts
> npm install --production
> ```
You will probably also want a default configuration you can fill in, which can be created using:
> `nodecg defaultconfig`.
Then, to get the most recent changes for [nodecg-speedcontrol](https://github.com/speedcontrol/nodecg-speedcontrol), clone the `build` branch and install dependencies, similar to above:
> ```
> cd ..
> git clone https://github.com/speedcontrol/nodecg-speedcontrol.git --branch build
> cd nodecg-speedcontrol
> npm install --production
> ```
In addition, to have the `videos` assets automatically audio normalised, you must have `python` (v3), `ffmpeg`, and [`ffmpeg-normalize`](https://github.com/slhck/ffmpeg-normalize) available in your system's `PATH`. If you don't have all of these, the check will fail and videos will just not be touched. For Windows, `python` (v3) should be automatically installed when you install Node.js built tools, if you chose to do that.
## Usage
*Not everything you can set is documented here; if you're an advanced user we advise you take a look at the included [configschema.json](configschema.json) file.*
This bundle heavily relies on the [obs-websocket](https://github.com/Palakis/obs-websocket) plugin, so make sure you have this installed (custom address/port and password can be specified in the bundle's config if needed).
This bundle also heavily relies on information from a RabbitMQ server, and an instance of our fork of the [GamesDoneQuick donation tracker](https://github.com/esamarathon/donation-tracker).
### Stream Deck Plugin
Included with this bundle is a plugin for the Elgato Stream Deck software that can be used by various crew members during events. Once you have the Stream Deck software installed, you can install the plugin by running the file `com.esamarathon.streamdeck.streamDeckPlugin` in the `streamdeck-plugin/Release` directory. Currently, you need to set the actions up yourself in the software, so it can easily be customised on the fly.
### FlagCarrier Configuration
You will need to install the [speedcontrol-flagcarrier](https://github.com/speedcontrol/speedcontrol-flagcarrier) bundle to use this part, along with using one of the FlagCarrier applications to set them.
**Hosts (the ones on camera):**
- group_id: `hosts`
- positions: `left,midleft,middle,midright,right`
### Text-To-Speech Donations
This can be enabled via the config, controlled via Stream Deck buttons available in the included extension, and the graphic files `tts.html` will play them when requested. You will need to set a specific URL for the `voiceAPI` setting in the config though, so unless you know this it's somewhat useless, sorry.
### Music Player
This bundle can interface with [foobar2000](https://www.foobar2000.org/) using the [beefweb](https://github.com/hyperblast/beefweb) plugin. Set up foobar2000 however you want it to play music (we use a long playlist on shuffle, and set a fade in/out on pause), make sure the correct username/password are set in the configuration fiole, and this bundle with automatically play music when needed. It will only play if the scene name ends in `[M]`, for example, `Intermission [M]`.
## Other Information
### Events Used For
Here's a list of events this bundle has been used at so far, most recent first.
* UKSG Autumn 2021
* ESA Summer 2021
* UKSG Summer 2021
* UKSG Spring 2021
* ESA Winter 2021
* UKSG Winter 2021
* UKSG Autumn 2020
* ESA Summer 2020
* UKSG Summer 2020
* ESA Corona Relief
* ESA Together
* UKSG Spring 2020
* ESA Winter 2020
* UKSG Winter 2020
* ESA @ Malmö Vinterspelen 2019
* ESA @ DreamHack Winter 2019
* UKSG Autumn 2019
* ESA Summer 2019 (including some streams on [SpeedGaming](https://www.twitch.tv/speedgaming) during the event).
* UKSG Summer 2019
* All BSG's from BSG @Home 2020 onwards (Aug 2020)
* All Hekathon events from 2021 onwards
### Previous Bundles
Here's a list of previous bundles that used to fulfil the purpose of this one, when we kept making new repositories for most of them.
* [esaw19-layouts](https://github.com/esamarathon/esaw19-layouts)
* ESA Winter 2019
* ESA @ TwitchCon Europe 2019
* [esas18-layouts](https://github.com/esamarathon/esas18-layouts)
* ESA Summer 2018
* UKSG Fall 2018
* ESA Movember
* UKSG Winter 2019
* UKSG Spring 2019
* [esaw18-layouts](https://github.com/esamarathon/esaw18-layouts)
* ESA Winter 2018
* [esa17-layouts](https://github.com/esamarathon/esa17-layouts)
* ESA 2017
### Credits
* Country flags sourced from [speedrun.com](https://www.speedrun.com/).
* [clip.ts](src/graphics/_misc/clip.ts), modified from a version originally written by [Hoishin](https://github.com/hoishin).

748
configschema.json Normal file
View File

@ -0,0 +1,748 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"bidwarBias": {
"type": "object",
"additionalProperties": false,
"properties": {
"bidId": {
"type": "number",
"default": 0,
"$comment": "ID of relevant bid in the tracker."
},
"option1Id": {
"type": "number",
"default": 0,
"$comment": "ID of option of team 1 in the tracker on the above bid."
},
"option2Id": {
"type": "number",
"default": 0,
"$comment": "ID of option of team 2 in the tracker on the above bid."
},
"optionTitle": {
"type": "string",
"default": "Commentary Bias",
"$comment": "String to be used on layout to describe the visual bar."
}
},
"required": [
"bidId",
"option1Id",
"option2Id",
"optionTitle"
]
}
},
"type": "object",
"additionalProperties": false,
"properties": {
"useTestData": {
"type": "boolean",
"default": false
},
"event": {
"type": "object",
"additionalProperties": false,
"properties": {
"theme": {
"$comment": "Theme to be used in the graphical overlays; will use default if none supplied.",
"type": "string"
},
"shorts": {
"$comment": "This/these must match the tracker, if that feature is enabled.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1,
"maxItems": 2
}
],
"default": "EVENT_SHORT"
},
"thisEvent": {
"$comment": "If the 'event' has multiple tracker events, this a 1-indexed value of which one is applicable to this stream from the shorts array.",
"type": "number",
"minimum": 1,
"maximum": 2,
"default": 1
},
"online": {
"$comment": "If this event is ran online and has no on-site presence.",
"oneOf": [
{
"type": "boolean"
},
{
"$comment": "If set to 'partial', will only do basic changes.",
"type": "string",
"enum": [
"partial",
"full"
]
}
],
"default": false
},
"fallbackTwitchTitle": {
"$comment": "Set the fallback Twitch title for this event; {{total}} and {{run}} can be used as placeholders (see source code).",
"type": "string"
}
},
"required": [
"shorts",
"thisEvent",
"online"
]
},
"omnibar": {
"type": "object",
"additionalProperties": false,
"properties": {
"miniCredits": {
"type": "object",
"additionalProperties": false,
"properties": {
"header": {
"type": "string",
"default": "Thanks to all these people!"
},
"screeners": {
"type": "string"
},
"tech": {
"type": "string"
}
},
"required": [
"header"
]
}
},
"required": [
"miniCredits"
]
},
"streamdeck": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"port": {
"type": "number",
"default": 9091
},
"key": {
"type": "string",
"default": "DEFAULT_KEY"
},
"debug": {
"type": "boolean",
"default": false
}
},
"required": [
"enabled",
"port",
"key",
"debug"
]
},
"rabbitmq": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"protocol": {
"type": "string",
"default": "amqps"
},
"hostname": {
"type": "string",
"default": "mq.esamarathon.com"
},
"username": {
"type": "string",
"default": "USERNAME"
},
"password": {
"type": "string",
"default": "PASSWORD"
},
"vhost": {
"type": "string",
"default": "esa_prod"
},
"queuePrepend": {
"type": "string"
}
},
"required": [
"enabled",
"protocol",
"hostname",
"username",
"password",
"vhost"
]
},
"obs": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"address": {
"type": "string",
"default": "localhost:4444"
},
"password": {
"type": "string",
"default": ""
},
"canvasResolution": {
"type": "object",
"additionalProperties": false,
"properties": {
"width": {
"type": "number",
"default": 1920
},
"height": {
"type": "number",
"default": 1080
}
},
"required": [
"width",
"height"
]
},
"names": {
"type": "object",
"additionalProperties": false,
"properties": {
"scenes": {
"type": "object",
"additionalProperties": false,
"properties": {
"commercials": {
"type": "string",
"default": "Intermission (commercials)"
},
"gameLayout": {
"type": "string",
"default": "Game Layout"
},
"readerIntroduction": {
"type": "string",
"default": "Reader Introduction"
},
"intermission": {
"type": "string",
"default": "Intermission"
},
"intermissionPlayer": {
"type": "string",
"default": "Intermission Player"
},
"countdown": {
"type": "string",
"default": "Countdown"
}
},
"required": [
"commercials",
"gameLayout",
"readerIntroduction",
"intermission",
"intermissionPlayer",
"countdown"
]
},
"sources": {
"type": "object",
"additionalProperties": false,
"properties": {
"gameSources": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1
}
],
"default": "Game Source"
},
"cameraSources": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1
}
],
"default": "Camera Source"
},
"cameraSourceCrowd": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"twitchSources": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1,
"maxItems": 2
}
],
"default": "Twitch Source"
},
"videoPlayer": {
"type": "string",
"default": "Video Player Source"
},
"donationSound": {
"type": "string",
"default": "Donation Sound"
}
},
"required": [
"gameSources",
"cameraSources",
"twitchSources",
"videoPlayer",
"donationSound"
]
},
"groups": {
"type": "object",
"additionalProperties": false,
"properties": {
"gameCaptures": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1
}
],
"default": "Game Capture"
},
"cameraCaptures": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1
}
],
"default": "Camera Capture"
}
},
"required": [
"gameCaptures",
"cameraCaptures"
]
}
},
"required": [
"scenes",
"sources",
"groups"
]
}
},
"required": [
"enabled",
"address",
"password",
"canvasResolution",
"names"
]
},
"music": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"address": {
"type": "string",
"default": "localhost:8880"
},
"username": {
"type": "string",
"default": ""
},
"password": {
"type": "string",
"default": ""
}
},
"required": [
"enabled",
"address",
"username",
"password"
]
},
"x32": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"ip": {
"type": "string",
"default": "10.20.30.42"
},
"localPort": {
"type": "number",
"default": 52361
},
"xr18": {
"type": "boolean",
"default": false
}
},
"required": [
"enabled",
"ip",
"localPort",
"xr18"
]
},
"xkeys": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
},
"required": [
"enabled"
]
},
"tracker": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"address": {
"type": "string",
"default": "donations.esamarathon.com"
},
"username": {
"type": "string",
"default": "USERNAME"
},
"password": {
"type": "string",
"default": "PASSWORD"
},
"prizesUrl": {
"type": "string",
"default": "prizes.esamarathon.com"
},
"commentaryBias": {
"$ref": "#/definitions/bidwarBias"
},
"otherBidwarBias": {
"$ref": "#/definitions/bidwarBias"
},
"donationTotalInTitle": {
"type": "boolean",
"default": true
}
},
"required": [
"enabled",
"address",
"username",
"password",
"prizesUrl",
"commentaryBias",
"otherBidwarBias",
"donationTotalInTitle"
]
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"voiceAPI": {
"type": "string",
"default": "URL"
},
"key": {
"type": "string",
"default": "TOKEN"
}
},
"required": [
"enabled",
"voiceAPI",
"key"
]
},
"flagcarrier": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"allowedDevices": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true,
"minItems": 1
},
{
"type": "null"
}
]
},
"group": {
"type": "string",
"default": "stream1"
},
"availableButtons": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"default": [
{
"id": "1",
"name": "PC 1"
},
{
"id": "2",
"name": "PC 2"
},
{
"id": "3",
"name": "Console 1"
},
{
"id": "4",
"name": "Console 2"
}
]
}
},
"required": [
"enabled",
"group",
"availableButtons"
]
},
"offsite": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"address": {
"type": "string",
"default": "https://app.esamarathon.com/offsite/api"
},
"key": {
"type": "string",
"default": "SECRET_KEY"
}
},
"required": [
"enabled",
"address",
"key"
]
},
"server": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"address": {
"type": "string",
"default": "https://register.esamarathon.com/api"
},
"key": {
"type": "string",
"default": "SECRET_KEY"
}
},
"required": [
"enabled",
"address",
"key"
]
},
"discord": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"token": {
"type": "string",
"default": "BOT_TOKEN"
},
"textChannelId": {
"type": "string",
"default": "TEXT_CHANNEL_ID"
}
},
"required": [
"enabled",
"token",
"textChannelId"
]
},
"streamlabsCharity": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"apiUrl": {
"type": "string",
"default": "API_URL"
}
},
"required": [
"enabled",
"apiUrl"
]
},
"therungg": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
},
"required": [
"enabled"
]
}
},
"required": [
"useTestData",
"event",
"omnibar",
"streamdeck",
"rabbitmq",
"obs",
"music",
"x32",
"xkeys",
"tracker",
"tts",
"flagcarrier",
"offsite",
"server",
"discord",
"streamlabsCharity",
"therungg"
]
}

View File

@ -0,0 +1 @@
BlankClip(length=72,width=16,height=9,pixel_type="RGB32",fps=60,audio_rate=0)

Binary file not shown.

Binary file not shown.

BIN
obs-assets/Stinger.webm Normal file

Binary file not shown.

1
obs-assets/encode.bat Normal file
View File

@ -0,0 +1 @@
ffmpeg -i "BlankTransition.avs" -codec:v qtrle "BlankTransition.mov"

10571
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

384
package.json Normal file
View File

@ -0,0 +1,384 @@
{
"name": "esa-layouts",
"version": "1.0.0",
"description": "The on-screen graphics used during European Speedrunner Assembly's \"marathon\" events.",
"homepage": "https://github.com/esamarathon/esa-layouts#readme",
"bugs": {
"url": "https://github.com/esamarathon/esa-layouts/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/esamarathon/esa-layouts.git"
},
"license": "MIT",
"author": "zoton2",
"contributors": [
"BtbN"
],
"scripts": {
"autofix": "run-s autofix:*",
"autofix:browser": "eslint --fix --ext .ts,.vue src/dashboard && eslint --fix --ext .ts,.vue src/graphics && eslint --fix --ext .ts,.vue src/browser_shared",
"autofix:extension": "eslint --fix --ext .ts src/extension && eslint --fix --ext .d.ts src/types",
"build": "run-s build:*",
"build:browser": "cross-env NODE_ENV=production webpack",
"build:extension": "tsc -b tsconfig.extension.json",
"clean": "trash node_modules/.cache && trash dashboard && trash graphics && trash extension",
"schema-types": "nodecg schema-types",
"start": "node ../..",
"watch": "run-p watch:*",
"watch:browser": "webpack -w",
"watch:extension": "tsc -b tsconfig.extension.json -w",
"postinstall": "cd shared && node postinstall.js"
},
"dependencies": {
"@esamarathon/mq-events": "^1.0.1",
"clone": "^2.1.2",
"discord.js": "^13.17.1",
"lodash": "^4.17.21",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
"needle": "^3.3.1",
"sharp": "^0.33.2",
"socket.io-client": "^4.7.4",
"speedcontrol-util": "github:speedcontrol/speedcontrol-util.git#build",
"streamdeck-util": "^0.4.0",
"uuid": "^9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@fontsource/barlow-condensed": "^4.5.9",
"@fontsource/montserrat": "^4.5.14",
"@fontsource/roboto": "^4.5.8",
"@mdi/font": "^7.4.47",
"@nodecg/types": "^2.1.12",
"@types/async": "^3.2.24",
"@types/clone": "^2.1.4",
"@types/lodash": "^4.14.202",
"@types/module-alias": "^2.0.4",
"@types/needle": "^3.3.0",
"@types/node": "^18.19.8",
"@types/sharp": "^0.31.1",
"@types/uuid": "^9.0.7",
"@types/webpack-env": "^1.18.4",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vue/component-compiler-utils": "^3.3.0",
"cross-env": "^7.0.3",
"css-loader": "^6.9.1",
"dayjs": "^1.11.10",
"deepmerge": "^4.3.1",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^16.2.0",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.8",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-vue": "^8.7.1",
"file-loader": "^6.2.0",
"fitty": "^2.4.2",
"fork-ts-checker-webpack-plugin": "^6.5.3",
"globby": "^12.2.0",
"gsap": "^3.12.5",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.7.7",
"nodecg-cli": "^8.6.8",
"npm-run-all": "^4.1.5",
"sass": "~1.32",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.4",
"trash-cli": "^5.0.0",
"ts-essentials": "^9.4.1",
"ts-loader": "^9.5.1",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.9.5",
"vue": "^2.7.15",
"vue-class-component": "^7.2.6",
"vue-eslint-parser": "^8.3.0",
"vue-hot-reload-api": "^2.3.4",
"vue-loader": "^15.11.1",
"vue-property-decorator": "^9.1.2",
"vue-router": "^3.6.5",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.7.16",
"vuedraggable": "^2.24.3",
"vuetify": "^2.7.1",
"vuetify-loader": "^1.9.2",
"vuex": "^3.6.2",
"vuex-class": "^0.3.2",
"vuex-class-state2way": "^1.0.1",
"vuex-module-decorators": "^1.2.0",
"webpack": "^5.89.0",
"webpack-cli": "^4.10.0",
"webpack-livereload-plugin": "^3.0.2"
},
"nodecg": {
"compatibleRange": "^1.9.0||^2",
"bundleDependencies": {
"nodecg-speedcontrol": "^2.4.0"
},
"dashboardPanels": [
{
"name": "game-layout-override",
"title": "Game Layout Override",
"width": 2,
"file": "game-layout-override.html",
"workspace": "ESA",
"headerColor": "#c49215"
},
{
"name": "media-box-control",
"title": "Media Box Control",
"width": 3,
"file": "media-box-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "intermission-slide-control",
"title": "Intermission Slide Control",
"width": 3,
"file": "intermission-slide-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "commentators",
"title": "Commentators",
"width": 2,
"file": "commentators.html",
"headerColor": "#c49215"
},
{
"name": "tts-control",
"title": "Text-To-Speech Control",
"width": 3,
"file": "tts-control.html",
"workspace": "Z8 - N/A",
"headerColor": "#c49215"
},
{
"name": "intermission-player-control",
"title": "Intermission Player Control",
"width": 3,
"file": "intermission-player-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "upcoming-run-control",
"title": "Upcoming Run Control",
"width": 3,
"file": "upcoming-run-control.html",
"workspace": "ESA",
"headerColor": "#c49215"
},
{
"name": "obs-control",
"title": "OBS Control",
"width": 3,
"file": "obs-control.html",
"headerColor": "#c49215"
},
{
"name": "donation-reader-control",
"title": "Donation Reader Control",
"width": 2,
"file": "donation-reader-control.html",
"headerColor": "#c49215"
},
{
"name": "countdown-control",
"title": "Countdown Control",
"width": 3,
"file": "countdown-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "omnibar-ticker-control",
"title": "Omnibar Ticker Control",
"width": 3,
"file": "omnibar-ticker-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "donation-alert-control",
"title": "Donation Alert Control",
"width": 3,
"file": "donation-alert-control.html",
"workspace": "Z2 - ESA Advanced",
"headerColor": "#c49215"
},
{
"name": "donation-total-milestones",
"title": "Donation Total Milestones",
"width": 4,
"file": "donation-total-milestones.html",
"workspace": "ESA",
"headerColor": "#c49215"
},
{
"name": "bigbutton-tag-scan-control",
"title": "Big Button Tag Scan Control",
"width": 3,
"file": "bigbutton-tag-scan-control.html",
"headerColor": "#c49215"
},
{
"name": "bids",
"title": "Bids",
"width": 3,
"file": "bids.html",
"workspace": "ESA",
"headerColor": "#c49215"
},
{
"name": "rabbitmq-test",
"title": "RabbitMQ Test",
"width": 3,
"file": "rabbitmq-test.html",
"workspace": "Z9 - Debug",
"headerColor": "#c49215"
}
],
"graphics": [
{
"file": "transition.html",
"width": 1920,
"height": 1000
},
{
"file": "omnibar.html",
"width": 1920,
"height": 80,
"singleInstance": true
},
{
"file": "countdown.html",
"width": 1920,
"height": 1080
},
{
"file": "intermission.html",
"width": 1920,
"height": 1080
},
{
"file": "intermission-hosts.html",
"width": 1920,
"height": 1080
},
{
"file": "intermission-player.html",
"width": 1920,
"height": 1080
},
{
"file": "reader-introduction.html",
"width": 1920,
"height": 1080
},
{
"file": "game-layout.html",
"width": 1920,
"height": 1080
},
{
"file": "unread-donations.html",
"width": 1920,
"height": 1080
},
{
"file": "tts-player.html",
"width": 1920,
"height": 1080
},
{
"file": "player-hud.html",
"width": 800,
"height": 480
},
{
"file": "media-box-only.html",
"width": 1920,
"height": 1080
}
],
"mount": [
{
"directory": "shared/flags",
"endpoint": "flags"
},
{
"directory": "boxart",
"endpoint": "boxart"
}
],
"assetCategories": [
{
"name": "media-box-images",
"title": "Media Box Images",
"allowedTypes": [
"jpg",
"jpeg",
"png",
"svg",
"webp",
"gif"
]
},
{
"name": "videos",
"title": "Videos",
"allowedTypes": [
"mp4",
"webm",
"MP4",
"WEBM"
]
},
{
"name": "intermission-slides",
"title": "Intermission Slide Images/Videos",
"allowedTypes": [
"jpg",
"jpeg",
"png",
"svg",
"mp4",
"webm",
"webp",
"gif"
]
},
{
"name": "reader-introduction-images",
"title": "Reader Introduction Images",
"allowedTypes": [
"jpg",
"jpeg",
"png",
"svg",
"webp",
"gif"
]
},
{
"name": "donation-alert-assets",
"title": "Donation Alert Assets",
"allowedTypes": [
"jpg",
"jpeg",
"png",
"svg",
"webp",
"gif",
"mp3",
"wav"
]
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

6606
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

76
schemas/bids.json Normal file
View File

@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"total": {
"type": "number"
},
"game": {
"type": "string"
},
"category": {
"type": "string"
},
"endTime": {
"type": "number"
},
"war": {
"type": "boolean"
},
"allowUserOptions": {
"type": "boolean"
},
"options": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"parent": {
"type": "number"
},
"name": {
"type": "string"
},
"total": {
"type": "number"
}
},
"required": [
"id",
"parent",
"name",
"total"
]
}
},
"goal": {
"type": "number"
}
},
"required": [
"id",
"name",
"total",
"war",
"allowUserOptions",
"options"
]
},
"default": []
}

View File

@ -0,0 +1,104 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"$comment": "Copied from @esamarathon/mq-events/definitions/FlagCarrier/TagScanned.json due to NodeCG issues.",
"additionalProperties": false,
"properties": {
"flagcarrier": {
"type": "object",
"required": [
"id",
"group",
"time",
"uid"
],
"properties": {
"id": {
"type": "string",
"description": "ID of the terminal that scanned the tag (BRB1, ...)",
"default": "unset"
},
"group": {
"type": "string",
"description": "Group of the terminal that scanned the tag",
"default": "unset"
},
"time": {
"type": "object",
"required": [
"iso",
"unix"
],
"properties": {
"iso": {
"type": "string",
"description": "Timestamp representation in the ISO 8601 format",
"format": "date-time",
"examples": [
"2019-09-03T19:55:18.430Z"
]
},
"unix": {
"type": "number",
"description": "Timestamp representation in seconds since the Unix epoch, including a fractional millisecond part",
"examples": [
1567540518.430
]
}
},
"additionalProperties": false,
"description": "Timestamp of when the tag was scanned"
},
"uid": {
"type": "string",
"description": "NFC tag UID as hex string"
},
"validSignature": {
"type": "boolean",
"description": "Indicates if tag had a valid signature"
},
"pubKey": {
"type": "string",
"description": "Base64 encoded ed25519 public key used to verify the tag"
}
},
"additionalProperties": false
},
"user": {
"type": "object",
"required": [
"displayName"
],
"properties": {
"id": {
"type": "string",
"description": "UserTool ID of the user who scanned the tag (if known)"
},
"displayName": {
"type": "string",
"description": "UserTool display name of the user who scanned the tag",
"default": "*unset*"
}
},
"additionalProperties": false
},
"raw": {
"type": "object",
"description": "Raw dump of scanned tags Key->Value data",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"flagcarrier",
"user",
"raw"
]
}
}
}

8
schemas/bundles.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "array",
"default": [],
"items": {
"type": "object"
}
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../shared/schemas/capturePositions.json"
}
]
}

View File

@ -0,0 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "array",
"items": {
"type": "string"
}
}

8
schemas/countdown.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../shared/schemas/countdown.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"audio": {
"type": "number",
"default": 0
},
"video": {
"type": "number",
"default": 0
}
},
"required": [
"audio",
"video"
]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../node_modules/speedcontrol-util/schemas/timer.json"
}
]
}

View File

@ -0,0 +1,53 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"threshold": {
"type": "number"
},
"sound": {
"description": "This stores a reference based on the 'name' of the asset.",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"volume": {
"type": "number"
},
"graphic": {
"description": "This stores a reference based on the 'name' of the asset.",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"graphicDisplayTime": {
"type": "number"
}
},
"required": [
"id",
"threshold",
"sound",
"volume",
"graphic",
"graphicDisplayTime"
]
},
"default": []
}

View File

@ -0,0 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
}

View File

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "number",
"default": 0
}

View File

@ -0,0 +1,31 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"addition": {
"type": "number"
},
"amount": {
"type": "number"
}
},
"required": [
"id",
"name",
"enabled"
]
},
"default": []
}

View File

@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"amount": {
"type": "number"
},
"comment": {
"type": "string"
},
"timestamp": {
"type": "number"
}
},
"required": [
"id",
"name",
"amount",
"timestamp"
]
},
"default": []
}

38
schemas/gameLayouts.json Normal file
View File

@ -0,0 +1,38 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"available": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"code": {
"type": "string"
}
},
"required": [
"name",
"code"
]
},
"default": []
},
"selected": {
"type": "string"
},
"crowdCamera": {
"type": "boolean",
"default": false
}
},
"required": [
"available",
"crowdCamera"
]
}

View File

@ -0,0 +1,52 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "array",
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"ipv4": {
"type": "string"
},
"timestamp": {
"type": "number"
},
"bundleName": {
"type": "string"
},
"bundleVersion": {
"type": "string"
},
"bundleGit": {
"type": "object"
},
"pathName": {
"type": "string"
},
"singleInstance": {
"type": "boolean"
},
"socketId": {
"type": "string"
},
"open": {
"type": "boolean"
},
"potentiallyOutOfDate": {
"type": "boolean"
}
},
"required": [
"ipv4",
"timestamp",
"bundleName",
"bundleVersion",
"pathName",
"singleInstance",
"socketId",
"open",
"potentiallyOutOfDate"
]
}
}

View File

@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"definitions": {
"types": {
"type": "string",
"enum": [
"UpcomingRuns",
"RandomBid",
"RandomPrize",
"Media"
]
}
},
"type": "object",
"additionalProperties": false,
"properties": {
"rotation": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"$ref": "#/definitions/types"
},
"id": {
"type": "string"
},
"mediaUUID": {
"type": "string"
}
},
"required": [
"type",
"id",
"mediaUUID"
]
},
"default": []
},
"current": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"$ref": "#/definitions/types"
},
"id": {
"type": "string"
},
"mediaUUID": {
"type": "string"
},
"bidId": {
"type": "number"
},
"prizeId": {
"type": "number"
}
},
"required": [
"type",
"id",
"mediaUUID"
]
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"rotation",
"current"
]
}

8
schemas/mediaBox.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../shared/schemas/mediaBox.json"
}
]
}

8
schemas/musicData.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../shared/schemas/musicData.json"
}
]
}

5
schemas/nameCycle.json Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "number",
"default": 0
}

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"event": {
"type": "string"
},
"_id": {
"type": "number"
},
"donor_visiblename": {
"type": "string"
},
"amount": {
"type": "number"
},
"comment_state": {
"type": "string"
},
"comment": {
"type": "string"
},
"time_received": {
"type": "string"
}
},
"required": [
"event",
"_id",
"donor_visiblename",
"amount",
"comment_state",
"comment",
"time_received"
]
},
"default": []
}

48
schemas/obsData.json Normal file
View File

@ -0,0 +1,48 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"connected": {
"type": "boolean",
"default": false
},
"scene": {
"type": "string"
},
"sceneList": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"transitioning": {
"type": "boolean",
"default": false
},
"streaming": {
"type": "boolean",
"default": false
},
"gameLayoutScreenshot": {
"type": "string"
},
"disableTransitioning": {
"type": "boolean",
"default": false
},
"transitionTimestamp": {
"type": "number",
"default": 0
}
},
"required": [
"connected",
"sceneList",
"transitioning",
"streaming",
"disableTransitioning",
"transitionTimestamp"
]
}

203
schemas/omnibar.json Normal file
View File

@ -0,0 +1,203 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"definitions": {
"props": {
"type": "object",
"additionalProperties": true,
"properties": {
"seconds": {
"type": "number"
}
}
},
"types": {
"rotation": {
"type": "string",
"enum": [
"GenericMsg",
"UpcomingRun",
"Prize",
"Bid",
"Milestone"
]
},
"alerts": {
"type": "string",
"enum": [
"Tweet",
"CrowdControl",
"MiniCredits"
]
},
"pins": {
"type": "string",
"enum": [
"Bid",
"Milestone"
]
}
}
},
"type": "object",
"additionalProperties": false,
"properties": {
"rotation": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"$ref": "#/definitions/types/rotation"
},
"id": {
"type": "string"
},
"props": {
"$ref": "#/definitions/props"
}
},
"required": [
"type",
"id"
]
},
"default": []
},
"alertQueue": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"$ref": "#/definitions/types/alerts"
},
"id": {
"type": "string"
},
"data": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"type",
"id"
]
},
"default": []
},
"current": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"oneOf": [
{
"$ref": "#/definitions/types/rotation"
},
{
"$ref": "#/definitions/types/alerts"
}
]
},
"id": {
"type": "string"
},
"props": {
"$ref": "#/definitions/props"
}
},
"required": [
"type",
"id"
]
},
{
"type": "null"
}
],
"default": null
},
"lastId": {
"type": "string"
},
"pin": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"$ref": "#/definitions/types/pins"
},
"id": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
}
]
}
},
"required": [
"type",
"id"
]
},
{
"type": "null"
}
],
"default": null
},
"miniCredits": {
"type": "object",
"additionalProperties": false,
"properties": {
"runSubs": {
"type": "array",
"items": {
"$comment": "TODO: Update MQ event!",
"type": "object",
"additionalProperties": true
},
"default": []
},
"runCheers": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
},
"default": []
},
"runDonations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
},
"default": []
}
},
"required": [
"runSubs",
"runCheers",
"runDonations"
]
}
},
"required": [
"rotation",
"alertQueue",
"current",
"pin",
"miniCredits"
]
}

View File

@ -0,0 +1,26 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"show": {
"type": "boolean",
"default": false
},
"runData": {
"oneOf": [
{
"$ref": "../node_modules/speedcontrol-util/schemas/reused/RunData.json"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"show",
"runData"
]
}

8
schemas/prizes.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"allOf": [
{
"$ref": "../shared/schemas/prizes.json"
}
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"current": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [
"RunInfo"
]
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"current"
]
}

View File

@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "number",
"default": 0
}

63
schemas/soundCues.json Normal file
View File

@ -0,0 +1,63 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"definitions": {
"cueFile": {
"type": ["object", "null"],
"properties": {
"sum": {
"type": "string"
},
"base": {
"type": "string"
},
"ext": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
},
"default": {
"type": "boolean"
}
}
}
},
"type": "array",
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"volume": {
"type": "number"
},
"channels": {
"type": "number"
},
"bundleName": {
"type": "string"
},
"defaultVolume": {
"type": ["number", "null"]
},
"file": {
"$ref": "#/definitions/cueFile"
},
"defaultFile": {
"$ref": "#/definitions/cueFile"
},
"assignable": {
"type": "boolean"
}
},
"required": ["name", "volume", "file", "assignable"]
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"playerHUDTriggerType": {
"type": "string"
}
}
}

View File

@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"start": {
"oneOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null
},
"end": {
"oneOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"start",
"end"
]
}

33
schemas/ttsVoices.json Normal file
View File

@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"available": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"code",
"name"
]
},
"default": []
},
"selected": {
"type": "string"
}
},
"required": [
"available"
]
}

View File

@ -0,0 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
}

63
schemas/videoPlayer.json Normal file
View File

@ -0,0 +1,63 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"playlist": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"sum": {
"type": "string"
},
"length": {
"type": "number"
},
"commercial": {
"type": "boolean"
}
},
"required": [
"length",
"commercial"
]
},
"default": []
},
"current": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"playing": {
"type": "boolean",
"default": false
},
"estimatedFinishTimestamp": {
"type": "number",
"default": 0
},
"plays": {
"type": "object",
"additionalProperties": {
"type": "number",
"default": 0
}
}
},
"required": [
"playlist",
"current",
"playing",
"estimatedFinishTimestamp",
"plays"
]
}

8
shared/.eslintignore Normal file
View File

@ -0,0 +1,8 @@
postinstall.js
.eslintrc.*.js
.eslintrc.js
types/schemas/**/*
flags/**/*
node_modules/**/*
schemas/**/*
dist

View File

@ -0,0 +1,72 @@
const path = require('path');
module.exports = {
root: true,
env: {
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
project: path.join(__dirname, 'tsconfig.browser.json'),
extraFileExtensions: ['.vue'],
ecmaVersion: 2020,
},
globals: {
nodecg: 'readonly',
NodeCG: 'readonly',
},
plugins: [
'@typescript-eslint',
],
extends: [
'plugin:vue/essential',
'airbnb-base',
'airbnb-typescript/base',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
settings: {
'import/resolver': {
typescript: {
// This is needed to properly resolve paths(?)
// Left commented out, might need again in the future.
// project: 'tsconfig.browser.json',
},
// This may be wanted/needed in the future.
/* webpack: {
config: path.join(__dirname, '../webpack.config.js'),
}, */
},
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
},
rules: {
'import/no-extraneous-dependencies': ['error', {
// Everything is compiled for the browser so dev dependencies are fine.
devDependencies: true,
packageDir: [path.join(__dirname, '.'), path.join(__dirname, '..')],
}],
// max-len set to ignore "import" lines (as they usually get long and messy).
'max-len': ['error', { code: 100, ignorePattern: '^import\\s.+\\sfrom\\s.+;$' }],
// I mainly have this off as it ruins auto import sorting in VSCode.
'object-curly-newline': 'off',
'@typescript-eslint/lines-between-class-members': 'off',
'vue/html-self-closing': ['error'],
'class-methods-use-this': 'off',
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: [
'state', // for vuex state
'acc', // for reduce accumulators
'e', // for e.returnvalue
],
}],
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
}],
}
};

View File

@ -0,0 +1,66 @@
const path = require('path');
module.exports = {
root: true,
env: {
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
project: path.join(__dirname, 'tsconfig.extension.json'),
},
plugins: [
'@typescript-eslint',
],
extends: [
'airbnb-base',
'airbnb-typescript/base',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
settings: {
'import/resolver': {
typescript: {
// This is needed to properly resolve paths(?)
// Left commented out, might need again in the future.
// project: 'tsconfig.extension.json',
},
},
'import/extensions': ['.js', '.jsx', '.ts', '.tsx'],
},
rules: {
'@typescript-eslint/lines-between-class-members': 'off',
// max-len set to ignore "import" lines (as they usually get long and messy).
'max-len': ['error', { code: 100, ignorePattern: '^import\\s.+\\sfrom\\s.+;' }],
// I mainly have this off as it ruins auto import sorting in VSCode.
'object-curly-newline': 'off',
'import/extensions': ['error', 'ignorePackages', {
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
}],
'import/no-extraneous-dependencies': ['error', {
packageDir: [path.join(__dirname, '.'), path.join(__dirname, '..')],
}],
'class-methods-use-this': 'off',
},
// Overrides for types.
overrides: [{
files: ['**/*.d.ts'],
rules: {
// @typescript-eslint/no-unused-vars does not work with type definitions
'@typescript-eslint/no-unused-vars': 'off',
// Sometimes eslint complains about this for types (usually when using namespaces).
'import/prefer-default-export': 'off',
// Types are only used for development (usually!) so dev dependencies are fine.
'import/no-extraneous-dependencies': ['error', {
devDependencies: true,
packageDir: [path.join(__dirname, '.'), path.join(__dirname, '..')],
}],
}
}],
};

4
shared/.eslintrc.js Normal file
View File

@ -0,0 +1,4 @@
// This is here just to make sure ESLint doesn't check any deeper.
module.exports = {
root: true,
};

126
shared/.gitignore vendored Normal file
View File

@ -0,0 +1,126 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

21
shared/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 European Speedrunner Assembly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

90
shared/README.md Normal file
View File

@ -0,0 +1,90 @@
# esa-layouts-shared
A repository which houses several elements that are used by mutiple [NodeCG](https://nodecg.dev) based bundles for our layouts, [esa-layouts](https://github.com/esamarathon/esa-layouts) for example.
**This repository is purposefully designed for our use, and can have breaking changes without prior notice. We advise you don't directly use it in any projects.**
## Basic notes for setup/structure
- This repository is to be used as a submodule, directly in the root of the NodeCG bundle (usually in a directory named `shared`).
- It requires the bundle to have some specific dependencies and structure; not going to note it all here because it's basically just the stuff from [zoton2/nodecg-vue-ts-template](https://github.com/zoton2/nodecg-vue-ts-template) which we tend to base bundles using.
- The bundle should have a `postinstall` in the `package.json` file:
- ```
"postinstall": "cd shared && node postinstall.js"
```
- You may want to add a `path` to your `tsconfig.*.json` files for ease of development:
- ```
"paths": {
"@shared/*": [
"shared/*"
],
}
```
- To make sure the above part works, you will also want to add this line in your `extension/index.ts` file:
- ```
require('module-alias').addAlias('@shared', require('path').join(__dirname, '../shared'));
```
- You will want to add these paths to your `tsconfig.browser.json` in the `include` array:
- ```
"include": [
// esa-layouts-shared
"shared/browser_shared/**/*.ts",
"shared/browser_shared/**/*.vue",
"shared/dashboard/**/*.ts",
"shared/dashboard/**/*.vue",
"shared/graphics/**/*.ts",
"shared/graphics/**/*.vue",
"shared/types/**/*.d.ts"
]
```
- You will want to add this path to yoiur `tsconfig.extension.json` in the `include` array:
- ```
"include": [
`"shared/types/**/*.d.ts"
]
```
- You will want to add these to your `tsconfig.extension.json` as `references`:
- ```
"references": [
{ "path": "./shared/extension/audio-normaliser" },
{ "path": "./shared/extension/countdown" },
{ "path": "./shared/extension/mediabox" },
{ "path": "./shared/extension/music" },
{ "path": "./shared/extension/obs" },
{ "path": "./shared/extension/rabbitmq" },
{ "path": "./shared/extension/video-player" },
{ "path": "./shared/extension/x32" },
{ "path": "./shared/extension/xkeys-esa" }
]
```
- You will want to add these entries in your `vetur.config.js` in the `projects` section:
- ```
projects: [
// esa-layouts-shared
{
root: './shared/dashboard',
package: '../../package.json',
},
{
root: './shared/graphics',
package: '../../package.json',
},
{
root: './shared/browser_shared',
package: '../../package.json',
},
]
```
- You will want to add this entry in your `.vscode/settings.json` file in the `eslint.workingDirectories` section:
- ```
"eslint.workingDirectories": [
"shared"
]
```
- You will want to change the Webpack `resolve.alias.vue` config to make sure it resolves to the one in the bundle:
- ```
alias: {
// vue: 'vue/dist/vue.esm.js',
vue: path.resolve(__dirname, 'node_modules/vue/dist/vue.esm.js'),
},
```

View File

@ -0,0 +1,5 @@
const path = require('path');
module.exports = {
extends: ['../.eslintrc.browser.js'],
};

View File

@ -0,0 +1,28 @@
/**
* Checks if number needs a 0 adding to the start and does so if needed.
* @param num Number which you want to turn into a padded string.
*/
export function padTimeNumber(num: number): string {
return num.toString().padStart(2, '0');
}
/**
* Converts milliseconds into a time string (HH:MM:SS).
* @param ms Milliseconds you wish to convert.
*/
export function msToTimeStr(ms: number): string {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${padTimeNumber(hours)
}:${padTimeNumber(minutes)
}:${padTimeNumber(seconds)}`;
}
/**
* Simple formatter for displaying USD amounts.
* @param amount Amount as a integer/float.
*/
export function formatUSD(amount: number): string {
return `$${amount.toFixed(2)}`;
}

View File

@ -0,0 +1,64 @@
import type NodeCGTypes from '@nodecg/types';
import clone from 'clone';
import Vue from 'vue';
import type { Store } from 'vuex';
import { namespace } from 'vuex-class';
import { getModule, Module, Mutation, VuexModule } from 'vuex-module-decorators';
import type { Countdown, MediaBox, Prizes } from '../types/schemas';
// Declaring replicants.
export const reps: {
assetsMediaBoxImages: NodeCGTypes.ClientReplicant<NodeCGTypes.AssetFile[]>;
countdown: NodeCGTypes.ClientReplicant<Countdown>;
mediaBox: NodeCGTypes.ClientReplicant<MediaBox>;
prizes: NodeCGTypes.ClientReplicant<Prizes>;
[k: string]: NodeCGTypes.ClientReplicant<unknown>;
} = {
assetsMediaBoxImages: nodecg.Replicant('assets:media-box-images'),
countdown: nodecg.Replicant('countdown'),
mediaBox: nodecg.Replicant('mediaBox'),
prizes: nodecg.Replicant('prizes'),
};
// All the replicant types.
export interface ReplicantTypes {
assetsMediaBoxImages: NodeCGTypes.AssetFile[];
countdown: Countdown;
mediaBox: MediaBox;
prizes: Prizes;
}
@Module({ name: 'ReplicantModule', namespaced: true })
export class ReplicantModule extends VuexModule {
// Replicant values are stored here.
reps: { [k: string]: unknown } = {};
// This sets the state object above when a replicant sends an update.
@Mutation
setState({ name, val }: { name: string, val: unknown }): void {
Vue.set(this.reps, name, clone(val));
}
// This is a generic mutation to update a named replicant.
@Mutation
setReplicant<K>({ name, val }: { name: string, val: K }): void {
Vue.set(this.reps, name, clone(val)); // Also update local copy, although no schema validation!
reps[name].value = clone(val);
}
}
// eslint-disable-next-line import/no-mutable-exports
export let replicantModule!: ReplicantModule;
export const replicantNS = namespace('ReplicantModule');
export async function setUpReplicants(store: Store<unknown>): Promise<void> {
// Listens for each declared replicants "change" event, and updates the state.
Object.keys(reps).forEach((name) => {
reps[name].on('change', (val) => {
store.commit('ReplicantModule/setState', { name, val });
});
});
// We should make sure the replicant are ready to be read, needs to be done in browser context.
await NodeCG.waitForReplicants(...Object.keys(reps).map((key) => reps[key]));
replicantModule = getModule(ReplicantModule, store);
}

View File

@ -0,0 +1,3 @@
{
"extends": "../tsconfig.browser.json"
}

View File

@ -0,0 +1,11 @@
const path = require('path');
module.exports = {
extends: ['../.eslintrc.browser.js'],
overrides: [{
files: ['**/index.ts'],
rules: {
'import/newline-after-import': 'off',
},
}],
};

View File

@ -0,0 +1,45 @@
<template>
<v-app>
<div>
Current Countdown: {{ currentCountdown }}
</div>
<v-time-picker
v-model="entry"
format="24hr"
full-width
/>
<v-btn @click="change()">
Apply
</v-btn>
</v-app>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import clone from 'clone';
import { msToTimeStr } from '../../browser_shared/helpers';
import { Countdown } from '../../types/schemas';
@Component
export default class extends Vue {
countdown: Countdown | null = null;
entry = '';
get currentCountdown(): string {
const seconds = Math.round((this.countdown?.remaining ?? 0) / 1000);
return msToTimeStr(seconds * 1000);
}
change(): void {
nodecg.sendMessage('startCountdown', this.entry);
this.entry = '';
}
created(): void {
// Simple replicant cloning to avoid having to use a whole Vuex store.
nodecg.Replicant<Countdown>('countdown').on('change', (val) => {
Vue.set(this, 'countdown', clone(val));
});
}
}
</script>

View File

@ -0,0 +1,2 @@
import App from './App.vue';
export default App;

View File

@ -0,0 +1,71 @@
<template>
<v-app>
<available-images />
<available-prizes v-if="prizes" :style="{ 'margin-top': '20px' }" />
<div :style="{ 'margin-top': '20px' }">
<v-toolbar-title>
Custom Text Element
</v-toolbar-title>
<div>
<draggable
:list="['text']"
:group="{ name: 'media', pull: 'clone', put: false }"
:sort="false"
:clone="cloneText"
>
<media-card key="text" :style="{ 'font-weight': '500' }">
Drag to rotation to configure a custom text element.
</media-card>
</draggable>
</div>
</div>
<rotation :style="{ 'margin-top': '20px' }" />
<!-- Save Button -->
<v-btn
:loading="disableSave"
:disabled="disableSave"
:style="{ 'margin-top': '20px' }"
@click="save()"
>
Save
</v-btn>
<current-media-info :style="{ 'margin-top': '10px' }" />
</v-app>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import Draggable from 'vuedraggable';
import { Action, State } from 'vuex-class';
import { MediaBox } from '../../types';
import AvailableImages from './components/AvailableImages.vue';
import AvailablePrizes from './components/AvailablePrizes.vue';
import CurrentMediaInfo from './components/CurrentMediaInfo.vue';
import MediaCard from './components/MediaCard.vue';
import Rotation from './components/Rotation.vue';
import { clone } from './components/shared';
import { Save, store } from './store';
@Component({
store,
components: {
Draggable,
AvailableImages,
AvailablePrizes,
Rotation,
CurrentMediaInfo,
MediaCard,
},
})
export default class extends Vue {
@Prop({ type: Boolean, default: true }) prizes!: boolean;
@State disableSave!: boolean;
@Action save!: Save;
cloneText(): MediaBox.RotationElem {
return clone('text', undefined, 'Your text here');
}
}
</script>

View File

@ -0,0 +1,44 @@
<template>
<v-tooltip
v-if="isApplicable === true"
right
>
<template v-slot:activator="{ on }">
<v-icon v-on="on">
mdi-check
</v-icon>
</template>
<span>Applicable</span>
</v-tooltip>
<v-tooltip
v-else-if="isApplicable === false"
right
>
<template v-slot:activator="{ on }">
<v-icon v-on="on">
mdi-close
</v-icon>
</template>
<span>Not Currently Applicable</span>
</v-tooltip>
<v-tooltip
v-else
right
>
<template v-slot:activator="{ on }">
<v-icon v-on="on">
mdi-help
</v-icon>
</template>
<span>Unknown</span>
</v-tooltip>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class extends Vue {
@Prop({ type: Boolean, required: false }) isApplicable!: boolean | undefined;
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div>
<v-toolbar-title>
Available Images
</v-toolbar-title>
<div
:style="{
'max-height': '400px',
'overflow-y': 'auto',
}"
>
<media-card
v-if="!images.length"
:style="{
'font-style': 'italic',
'white-space': 'unset',
}"
>
Add images under "Assets" > "{{ bundleName }}" > "Media Box Images".
</media-card>
<draggable
v-else
:list="images"
:group="{ name: 'media', pull: 'clone', put: false }"
:sort="false"
:clone="clone"
>
<media-card
v-for="image in images"
:key="image.sum"
:title="image.name"
>
{{ image.name }}
</media-card>
</draggable>
</div>
</div>
</template>
<script lang="ts">
import type NodeCGTypes from '@nodecg/types';
import { Component, Vue } from 'vue-property-decorator';
import Draggable from 'vuedraggable';
import { State } from 'vuex-class';
import { MediaBox } from '../../../types';
import MediaCard from './MediaCard.vue';
import { clone } from './shared';
@Component({
components: {
Draggable,
MediaCard,
},
})
export default class extends Vue {
@State images!: NodeCGTypes.AssetFile[];
bundleName = nodecg.bundleName;
clone(original: NodeCGTypes.AssetFile): MediaBox.RotationElem {
return clone('image', original.sum);
}
}
</script>

View File

@ -0,0 +1,95 @@
<template>
<div>
<v-toolbar-title>
Available Prizes
</v-toolbar-title>
<div
:style="{
'max-height': '400px',
'overflow-y': 'auto',
}"
>
<media-card
v-if="!prizes.length"
:style="{ 'font-style': 'italic' }"
>
No prizes available from the tracker.
</media-card>
<!-- All Prizes -->
<draggable
v-else
:list="prizes"
:group="{ name: 'media', pull: 'clone', put: false }"
:sort="false"
:clone="clone"
>
<media-card
v-for="prize in prizes"
:key="prize.id"
class="d-flex"
>
<applicable-icon :is-applicable="isPrizeApplicable(prize)" />
<div
class="flex-grow-1"
:title="prize.name"
>
{{ prize.name }}
</div>
</media-card>
</draggable>
<!-- Generic Prize Slide -->
<draggable
:list="['generic_prize']"
:group="{ name: 'media', pull: 'clone', put: false }"
:sort="false"
:clone="cloneGeneric"
>
<media-card
key="generic_prize"
class="d-flex"
:style="{ 'font-weight': '500' }"
>
<applicable-icon :is-applicable="!!prizes.filter((p) => isPrizeApplicable(p)).length" />
<div
class="flex-grow-1"
title="Generic Prize Slide"
>
Generic Prize Slide
</div>
</media-card>
</draggable>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
import { State } from 'vuex-class';
import Draggable from 'vuedraggable';
import { Tracker, MediaBox } from '../../../types';
import { Prizes } from '../../../types/schemas';
import { clone, isPrizeApplicable } from './shared';
import MediaCard from './MediaCard.vue';
import ApplicableIcon from './ApplicableIcon.vue';
@Component({
components: {
Draggable,
MediaCard,
ApplicableIcon,
},
})
export default class extends Vue {
@State prizes!: Prizes;
isPrizeApplicable = isPrizeApplicable;
clone(original: Tracker.FormattedPrize): MediaBox.RotationElem {
return clone('prize', original.id.toString());
}
cloneGeneric(): MediaBox.RotationElem {
return clone('prize_generic');
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<div>
<div class="Status">
<span
v-if="!settings.current"
:style="{ 'font-style': 'italic' }"
>
No media currently displaying.
</span>
<template v-else-if="settings.current">
<span class="font-weight-bold">Current:</span>
<template v-if="isAlertType(settings.current.type)">
<span :style="{ 'text-transform': 'capitalize' }">
{{ settings.current.type }}
</span> Alert
</template>
<template v-else>
{{ getMediaDetails(settings.current).name }}
</template>
<br>
<template v-if="!isAlertType(settings.current.type)">
(position {{ position(settings.current) }}/{{ settings.rotationApplicable.length }},
</template>
<span v-else>(</span>{{
timeRemaining(settings.current) }}/{{ mediaLength(settings.current) }}s left)
</template>
</div>
<div
v-if="settings.paused"
class="Status"
>
<span class="font-weight-bold">Paused:</span> {{ getMediaDetails(settings.paused).name }}
<br>(position {{ position(settings.paused) }}/{{ settings.rotationApplicable.length }},
{{ timeRemaining(settings.paused) }}/{{ mediaLength(settings.paused) }}s left)
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { State } from 'vuex-class';
import { MediaBox } from '../../../types';
import { MediaBox as MediaBoxRep } from '../../../types/schemas';
import { getMediaDetails, isAlertType } from './shared';
@Component
export default class extends Vue {
@State settings!: MediaBoxRep;
getMediaDetails = getMediaDetails;
isAlertType = isAlertType;
mediaLength(media: MediaBox.ActiveElem): number {
if (media && isAlertType(media.type)) {
return 15; // Alerts have a hardcoded 15 second length for now.
}
return this.settings.rotationApplicable
.find((i) => i.id === media?.id)?.seconds || 0;
}
timeRemaining(media: MediaBox.ActiveElem): number {
return Math.round(this.mediaLength(media) - ((media?.timeElapsed || 0) / 1000));
}
position(media: MediaBox.ActiveElem): number {
const index = media?.index;
return typeof index === 'number' ? index + 1 : -1;
}
}
</script>
<style scoped>
.Status {
text-align: center;
padding: 5px;
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<v-card
:style="{
'text-align': 'center',
padding: '5px',
'margin-top': '10px',
'white-space': 'nowrap',
'overflow': 'hidden',
}"
>
<slot />
</v-card>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';
@Component
export default class extends Vue {}
</script>

View File

@ -0,0 +1,181 @@
<template>
<div>
<!-- Dialog for editing custom text -->
<v-dialog class="Dialog" v-model="dialog" persistent>
<v-card>
<div class="pa-4 pb-0">
Text entered here can include Markdown for styling purposes.
</div>
<v-card-text class="pa-4 pb-0">
<v-form>
<v-textarea
v-model="editedText"
label="Text"
autocomplete="off"
filled
dense
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="save">Save</v-btn>
<v-btn @click="dialog = false; editedText = ''; editingElem = ''">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-toolbar-title>
Rotation
</v-toolbar-title>
<div
:style="{
'max-height': '400px',
'overflow-y': 'auto',
}"
>
<media-card
v-if="!newRotation.length"
:style="{ 'font-style': 'italic' }"
>
Drag elements from above to here to configure.
</media-card>
<draggable
v-model="newRotation"
group="media"
>
<media-card
v-for="(media, i) in newRotation"
:key="media.id"
class="d-flex"
>
<applicable-icon :is-applicable="isApplicable(media)" />
<div
class="d-flex align-center justify-center flex-grow-1"
:title="getMediaDetails(media).name"
:style="{
'overflow': 'hidden',
'font-weight': media.type === 'prize_generic' ? '500' : undefined,
'font-style': !getMediaDetails(media).name ? 'italic' : undefined,
}"
>
{{ getMediaDetails(media).name || 'Could not find media name.' }}
</div>
<div class="d-flex">
<v-tooltip left>
<template v-slot:activator="{ on }">
<div v-on="on">
<v-checkbox
v-on="on"
v-model="media.showOnIntermission"
dense
class="ma-0 pa-0"
hide-details
/>
</div>
</template>
<span>Show On Intermission</span>
</v-tooltip>
<v-text-field
v-model="media.seconds"
class="pa-0 ma-0"
type="number"
hide-details
dense
:style="{ 'width': '40px !important' }"
@input="parseSeconds(i)"
/>
<v-icon
v-if="media.type === 'text'"
@click="editingElem = media.id; editedText = media.text || ''; dialog = true"
>
mdi-pencil
</v-icon>
<v-icon @click="remove(i)">
mdi-delete
</v-icon>
</div>
</media-card>
</draggable>
</div>
</div>
</template>
<script lang="ts">
import type NodeCGTypes from '@nodecg/types';
import clone from 'clone';
import { Component, Vue } from 'vue-property-decorator';
import Draggable from 'vuedraggable';
import { State } from 'vuex-class';
import { State2Way } from 'vuex-class-state2way';
import { MediaBox } from '../../../types';
import { MediaBox as MediaBoxRep, Prizes } from '../../../types/schemas';
import ApplicableIcon from './ApplicableIcon.vue';
import MediaCard from './MediaCard.vue';
import { getMediaDetails, isPrizeApplicable } from './shared';
@Component({
components: {
Draggable,
MediaCard,
ApplicableIcon,
},
})
export default class extends Vue {
@State images!: NodeCGTypes.AssetFile[];
@State prizes!: Prizes;
@State settings!: MediaBoxRep;
@State2Way('updateNewRotation', 'newRotation') newRotation!: MediaBox.RotationElem[];
getMediaDetails = getMediaDetails;
isPrizeApplicable = isPrizeApplicable;
dialog = false;
editingElem = '';
editedText = '';
created(): void {
this.newRotation = clone(this.settings.rotation);
}
isApplicable(media: MediaBox.RotationElem): boolean | undefined {
// TODO: Check if on intermission on the dashboard size.
// We should probably just be loading in the server applicable rotation here.
if (!media.showOnIntermission) {
return undefined;
}
// Only applicable if the asset actually exists.
if (media.type === 'image') {
return !!this.images.find((i) => i.sum === media.mediaUUID);
}
// Generic prize element only applicable if there are applicable prizes to fill it with.
if (media.type === 'prize_generic') {
return !!this.prizes.filter((p) => isPrizeApplicable(p)).length;
}
// Check if prize is applicable using other function.
if (media.type === 'prize') {
return isPrizeApplicable(this.prizes.find((p) => p.id.toString() === media.mediaUUID));
}
// Text is always applicable.
if (media.type === 'text') {
return true;
}
return false;
}
parseSeconds(i: number): void {
this.newRotation[i].seconds = Number(this.newRotation[i].seconds);
}
save(): void {
const index = this.newRotation.findIndex((v) => v.id === this.editingElem);
if (index >= 0) {
this.newRotation[index].text = this.editedText;
}
this.editedText = '';
this.editingElem = '';
this.dialog = false;
}
remove(i: number): void {
this.newRotation.splice(i, 1);
}
}
</script>

View File

@ -0,0 +1,71 @@
import type NodeCGTypes from '@nodecg/types';
import { v4 as uuid } from 'uuid';
import { MediaBox, Tracker } from '../../../types';
import { store } from '../store';
/**
* Checks if the supplied type is that of an alert.
* @param type Type of alert
*/
export function isAlertType(type: MediaBox.Types): boolean {
return ['donation', 'subscription', 'cheer', 'merch', 'therungg'].includes(type);
}
/**
* Returns details about a piece of media from rotation if found.
* @param media Media from rotation you wish to query information on.
*/
export function getMediaDetails(
media: MediaBox.RotationElem | NonNullable<MediaBox.ActiveElem>,
): { name?: string } {
let details: NodeCGTypes.AssetFile | Tracker.FormattedPrize | undefined;
if (media.type === 'prize_generic') {
return {
name: 'Generic Prize Slide',
};
}
if (media.type === 'image') {
details = store.state.images.find((l) => l.sum === media.mediaUUID);
} else if (media.type === 'prize') {
details = store.state.prizes.find((p) => p.id.toString() === media.mediaUUID);
} else if (media.type === 'text') {
return {
// This cast type is technically wrong but works OK in this context.
name: (media as MediaBox.RotationElem).text
? (media as MediaBox.RotationElem).text
: 'Custom Text',
};
}
return details ? {
name: details.name,
} : {};
}
/**
* Used by VueDraggble to properly clone items.
* @param type Type of item to be cloned.
* @param mediaUUID UUID of media, sum of image, ID of prize etc.
*/
export function clone(
type: 'image' | 'prize' | 'prize_generic' | 'text',
mediaUUID?: string,
text?: string,
): MediaBox.RotationElem {
return {
type,
id: uuid(),
mediaUUID: mediaUUID || '-1',
text,
seconds: 60,
showOnIntermission: true,
};
}
/**
* Returns if a prize should be shown or not.
* @param prize Formatted prize object from the tracker.
*/
export function isPrizeApplicable(prize?: Tracker.FormattedPrize): boolean {
return !!(prize && prize.startTime && prize.endTime
&& Date.now() > prize.startTime && Date.now() < prize.endTime);
}

View File

@ -0,0 +1,3 @@
import App from './App.vue';
export { setUpReplicants } from './store';
export default App;

View File

@ -0,0 +1,70 @@
import type NodeCGTypes from '@nodecg/types';
import clone from 'clone';
import Vue from 'vue';
import Vuex, { Store } from 'vuex';
import type { MediaBox } from '../../types';
import type { MediaBox as MediaBoxRep, Prizes } from '../../types/schemas';
Vue.use(Vuex);
// Replicants and their types
const reps: {
images: NodeCGTypes.ClientReplicant<NodeCGTypes.AssetFile[]>;
prizes: NodeCGTypes.ClientReplicant<Prizes>;
settings: NodeCGTypes.ClientReplicant<MediaBoxRep>;
[k: string]: NodeCGTypes.ClientReplicant<unknown>;
} = {
images: nodecg.Replicant('assets:media-box-images'),
prizes: nodecg.Replicant('prizes'),
settings: nodecg.Replicant('mediaBox'),
};
interface StateTypes {
images: NodeCGTypes.AssetFile[];
prizes: Prizes;
disableSave: boolean;
newRotation: MediaBox.RotationElem[];
}
// Types for mutations/actions below
export type UpdateNewRotation = (arr: MediaBox.RotationElem[]) => void;
export type Save = () => void;
export const store = new Vuex.Store({
state: {
images: [],
prizes: [],
disableSave: false,
newRotation: [],
} as StateTypes,
mutations: {
setState(state, { name, val }): void {
Vue.set(state, name, val);
},
updateNewRotation(state, arr): void {
Vue.set(state, 'newRotation', arr);
},
},
actions: {
async save({ state }): Promise<void> {
Vue.set(state, 'disableSave', true);
if (typeof reps.settings.value !== 'undefined') {
reps.settings.value.rotation = clone(state.newRotation);
}
await new Promise((res) => { setTimeout(res, 1000); }); // Fake 1s wait
Vue.set(state, 'disableSave', false);
},
},
});
Object.keys(reps).forEach((key) => {
reps[key].on('change', (val) => {
store.commit('setState', { name: key, val: clone(val) });
});
});
export const setUpReplicants = async (): Promise<Store<StateTypes>> => {
await NodeCG.waitForReplicants(...Object.keys(reps).map((key) => reps[key]));
return store;
};
export default setUpReplicants;

View File

@ -0,0 +1,93 @@
<template>
<v-app
v-if="!useTestData"
:style="{ 'font-style': 'italic' }"
>
Not using test data.
</v-app>
<v-app
v-else-if="!enabled"
:style="{ 'font-style': 'italic' }"
>
RabbitMQ not enabled.
</v-app>
<v-app v-else>
<v-btn @click="donation">
Donation
</v-btn>
<v-btn @click="subscription">
Subscription
</v-btn>
<v-btn @click="cheer">
Cheer
</v-btn>
<v-btn @click="tweet">
Tweet
</v-btn>
<v-btn @click="crowdControl">
Crowd Control
</v-btn>
<div class="d-flex align-center">
<span title="ExampleUser1, he/him, exampleuser1, DE">Scan Tag 1:</span>
<v-btn @click="scanTag(1, '1')">B.1</v-btn>
<v-btn @click="scanTag(1, '2')">B.2</v-btn>
<v-btn @click="scanTag(1, '3')">B.3</v-btn>
</div>
<div class="d-flex align-center">
<span title="ExampleUser2, she/her, exampleuser2, SE">Scan Tag 2:</span>
<v-btn @click="scanTag(2, '1')">B.1</v-btn>
<v-btn @click="scanTag(2, '2')">B.2</v-btn>
<v-btn @click="scanTag(2, '3')">B.3</v-btn>
</div>
<div class="d-flex align-center">
<span title="ExampleUser3, they/them, exampleuser3, FI">Scan Tag 3:</span>
<v-btn @click="scanTag(3, '1')">B.1</v-btn>
<v-btn @click="scanTag(3, '2')">B.2</v-btn>
<v-btn @click="scanTag(3, '3')">B.3</v-btn>
</div>
<div class="d-flex align-center">
<span title="ExampleUser, no pronouns, no Twitch, no country">Scan Tag 4:</span>
<v-btn @click="scanTag(4, '1')">B.1</v-btn>
<v-btn @click="scanTag(4, '2')">B.2</v-btn>
<v-btn @click="scanTag(4, '3')">B.3</v-btn>
</div>
<div class="d-flex align-center">
Press Button:
<v-btn @click="pressBtn(1)">B.1</v-btn>
<v-btn @click="pressBtn(2)">B.2</v-btn>
<v-btn @click="pressBtn(3)">B.3</v-btn>
</div>
</v-app>
</template>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';
@Component
export default class extends Vue {
@Prop(Boolean) enabled!: boolean;
@Prop(Boolean) useTestData!: boolean;
donation(): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'donationFullyProcessed' });
}
subscription(): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'newScreenedSub' });
}
cheer(): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'newScreenedCheer' });
}
tweet(): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'newScreenedTweet' });
}
crowdControl(): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'newScreenedCrowdControl' });
}
scanTag(tag: number, id: string): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'bigbuttonTagScanned', data: { tag, id } });
}
pressBtn(id: number): void {
nodecg.sendMessage('testRabbitMQ', { msgType: 'bigbuttonPressed', data: { id } });
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More