UNPKG

@nodecg/types

Version:

Dynamic broadcast graphics rendered in a browser

695 lines (601 loc) 18.2 kB
import "@webcomponents/webcomponentsjs/webcomponents-loader"; import "@webcomponents/shadycss/apply-shim.min.js"; import "@polymer/app-layout/app-layout.js"; import "@polymer/app-route/app-location.js"; import "@polymer/app-route/app-route.js"; import "@polymer/iron-flex-layout/iron-flex-layout.js"; import "@polymer/iron-icons/av-icons.js"; import "@polymer/iron-icons/communication-icons.js"; import "@polymer/iron-icons/image-icons.js"; import "@polymer/iron-icons/iron-icons.js"; import "@polymer/iron-image/iron-image.js"; import "@polymer/iron-media-query/iron-media-query.js"; import "@polymer/iron-pages/iron-pages.js"; import "@polymer/iron-selector/iron-selector.js"; import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable.js"; import "@polymer/paper-dialog/paper-dialog.js"; import "@polymer/paper-icon-button/paper-icon-button.js"; import "@polymer/paper-item/paper-icon-item.js"; import "@polymer/paper-tabs/paper-tabs.js"; import "@polymer/paper-toast/paper-toast.js"; import "@polymer/polymer/lib/elements/custom-style.js"; import "../css/nodecg-theme"; import "./assets/ncg-assets"; import "./graphics/ncg-graphics"; import "./mixer/ncg-mixer"; import "./ncg-dialog"; import "./ncg-workspace"; import "./settings/ncg-settings"; import * as Polymer from "@polymer/polymer"; import { timeOut } from "@polymer/polymer/lib/utils/async.js"; import { Debouncer } from "@polymer/polymer/lib/utils/debounce.js"; import type { NodeCG } from "../../../types/nodecg"; class NcgDashboard extends Polymer.PolymerElement { static get template() { return Polymer.html` <style include="nodecg-theme"> :host { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @apply --layout-fullbleed; overflow: hidden; } app-drawer-layout, app-header-layout { height: 100%; } app-header { background-color: #2F3A4F; --app-header-shadow: { box-shadow: inset 0 5px 6px -3px rgba(0, 0, 0, 0.2); height: 10px; bottom: -10px; }; } app-drawer { --app-drawer-content-container: { background-color: #2F3A4F; }; } .spacer { @apply --layout-center-center; @apply --layout-flex-auto; @apply --layout; } paper-tabs { @apply --layout-center; @apply --layout-horizontal; height: 100%; --paper-tabs-selection-bar-color: var(--nodecg-brand-blue); --paper-tabs-selection-bar: { border-bottom-width: 5px; }; } paper-tab { @apply --layout-flex-none; font-size: 12px; text-transform: uppercase; user-select: none; --paper-tab-ink: var(--nodecg-brand-blue); --paper-tab-content: { font-weight: 500!important; /* don't bold when focused */ color: var(--nodecg-brand-blue); @apply --layout-vertical; @apply --layout-center-center; }; --paper-tab-content-unselected: { color: white; }; } .workspaceTab { font-size: 16px; } paper-icon-button { --paper-icon-button-ink-color: var(--nodecg-brand-blue); color: white; } #mainLogo { height: 48px; width: 48px; } #drawerToolbar { @apply --layout-horizontal; @apply --layout-center; } #drawerLogo { height: 28px; padding-left: 16px; width: 83px; } .button-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } paper-icon-item { --paper-item-icon: { min-width: 56px; } } .list { margin-left: 20px; margin-right: 20px; } .list a { color: white; display: block; line-height: 40px; text-decoration: none; text-transform: uppercase; } .list a.iron-selected { background-color: #525F78; } .list a.iron-selected paper-icon-item { font-weight: bold; } ncg-dialog paper-dialog-scrollable { --paper-dialog-scrollable: { max-width: none!important; } } section { @apply --layout-flex; box-sizing: border-box; } section:not(.section-workspace) { @apply --layout-center; @apply --layout-vertical; padding: 32px; } section.section-workspace { height: 1px; } #pages { @apply --layout-flex-auto; @apply --layout-vertical; box-sizing: border-box; height: 1px; /* just need to specify *any* height, the rest is then taken care of somehow? */ overflow: auto; } #reconnectToast { @apply --layout-center; @apply --layout-horizontal; } #reconnectToast paper-spinner { margin-left: 1em; } paper-spinner { --paper-spinner-layer-1-color: #645BA6; --paper-spinner-layer-2-color: #A50074; --paper-spinner-layer-3-color: #5BA664; --paper-spinner-layer-4-color: #C9513E; --paper-spinner-stroke-width: 5px; } [hidden] { display: none !important; } #dialogs iframe { box-sizing: border-box; margin: 0 !important; padding: 0 !important; width: 100%; } /** * Phone */ @media (max-width: 640px) { section { padding: 0!important; } ncg-workspace:not([fullbleed]) { padding: 6px; } paper-tabs { display: none; } #mainLogo { /* makes the menu button have equal negative space on the left and the right */ padding-left: 16px; } } </style> <app-location route="{{route}}" use-hash-as-path=""></app-location> <app-route route="{{route}}" pattern=":page" data="{{routeData}}" tail="{{subRoute}}"></app-route> <iron-media-query query="max-width: 640px" query-matches="{{smallScreen}}"></iron-media-query> <app-drawer-layout drawer-width="288px" force-narrow=""> <!-- navigation drawer for small screen sizes --> <app-drawer id="drawer" swipe-open="[[smallScreen]]" slot="drawer"> <template is="dom-if" if="[[smallScreen]]"> <app-toolbar id="drawerToolbar"> <paper-icon-button icon="close" aria-label="Close" drawer-toggle=""></paper-icon-button> <img id="drawerLogo" src="/dashboard/img/horiz-logo-2x.png" alt="NodeCG"> </app-toolbar> <iron-selector class="list" selected="[[routeData.page]]" attr-for-selected="data-route"> <template is="dom-repeat" items="[[workspaces]]" as="workspace" initial-count="1"> <a data-route\$="[[workspace.route]]" href="#[[workspace.route]]" aria-label="[[workspace.name]]" on-tap="closeDrawer"> <paper-icon-item> <iron-icon slot="item-icon" icon="dashboard"></iron-icon> <span class="button-label">[[workspace.label]]</span> </paper-icon-item> </a> </template> <template is="dom-repeat" items="[[pages]]" as="page" initial-count="1"> <a data-route\$="[[page.route]]" href="#[[page.route]]" aria-label="[[page.name]]" on-tap="closeDrawer"> <paper-icon-item> <iron-icon slot="item-icon" icon="[[page.icon]]"></iron-icon> [[page.name]] </paper-icon-item> </a> </template> </iron-selector> </template> </app-drawer> <app-header-layout fullbleed=""> <!-- main header --> <app-header id="header" shadow="" slot="header"> <app-toolbar id="mainToolbar"> <paper-icon-button icon="menu" drawer-toggle="" alt="Toogle navigation menu" hidden="[[!smallScreen]]"></paper-icon-button> <img id="mainLogo" src="/dashboard/img/square-logo.png" alt="NodeCG"> <template is="dom-if" if="[[!smallScreen]]"> <paper-tabs class="spacer" scrollable="" selected="[[routeData.page]]" attr-for-selected="route"> <template is="dom-repeat" items="[[workspaces]]" as="workspace" on-dom-change="_fixTabs"> <paper-tab class="workspaceTab" data-route\$="[[workspace.route]]" route="[[workspace.route]]" aria-label="[[workspace.name]]" on-tap="_selectRoute"> [[workspace.label]] </paper-tab> </template> </paper-tabs> <paper-tabs selected="[[routeData.page]]" attr-for-selected="route"> <template is="dom-repeat" items="[[pages]]" as="page" initial-count="1"> <paper-tab data-route\$="[[page.route]]" route="[[page.route]]" aria-label="[[page.name]]" on-tap="_selectRoute"> <iron-icon icon="[[page.icon]]"></iron-icon> [[page.name]] </paper-tab> </template> <paper-tab hidden="[[loginDisabled]]" aria-label="Sign Out" on-tap="logout"> <iron-icon icon="exit-to-app"></iron-icon> Sign Out </paper-tab> </paper-tabs> </template> </app-toolbar> </app-header> <iron-pages id="pages" selected="[[route.path]]" attr-for-selected="route"> <template is="dom-repeat" items="[[workspaces]]" as="workspace"> <section route="[[workspace.route]]" class="section-workspace"> <ncg-workspace workspace="[[workspace]]" route="[[route]]"></ncg-workspace> </section> </template> <section route="graphics"> <ncg-graphics></ncg-graphics> </section> <section route="mixer"> <ncg-mixer></ncg-mixer> </section> <section route="assets"> <ncg-assets></ncg-assets> </section> <section route="settings"> <ncg-settings></ncg-settings> </section> </iron-pages> </app-header-layout> </app-drawer-layout> <div id="dialogs"> <template is="dom-repeat" items="[[dialogs]]" as="dialog"> <ncg-dialog id="[[dialog.bundleName]]_[[dialog.name]]" bundle="[[dialog.bundleName]]" panel="[[dialog.name]]" width="[[dialog.width]]" with-backdrop=""> <h2 hidden="[[_falsey(dialog.title)]]">[[dialog.title]]</h2> <paper-dialog-scrollable> <iframe src="/bundles/[[dialog.bundleName]]/dashboard/[[dialog.file]]" frameborder="0" scrolling="no" id="[[dialog.bundleName]]_[[dialog.name]]_iframe" loading="lazy"> </iframe> </paper-dialog-scrollable> <div class="buttons" hidden="[[_falsey(dialog.dialogButtons)]]"> <template is="dom-repeat" items="[[dialog.dialogButtons]]" as="button"> <paper-button class\$="[[_calcButtonClass(button.type)]]" dialog-confirm\$="[[_equal(button.type, 'confirm')]]" dialog-dismiss\$="[[_equal(button.type, 'dismiss')]]"> [[button.name]] </paper-button> </template> </div> </ncg-dialog> </template> </div> <paper-toast id="mainToast"></paper-toast> <paper-toast id="reconnectToast" text="Attempting to reconnect to NodeCG server..." duration="0"> <paper-spinner active="[[disconnected]]"></paper-spinner> </paper-toast> `; } static get is() { return "ncg-dashboard"; } static get properties() { return { route: { type: Object, observer: "_routeChanged", }, smallScreen: { type: Boolean, observer: "_smallScreenChanged", }, loginDisabled: { type: Boolean, value: !window.ncgConfig.login?.enabled, }, bundles: { type: Array, value: window.__renderData__.bundles, }, workspaces: { type: Array, value: window.__renderData__.workspaces, }, dialogs: { type: Array, computed: "_computeDialogs(bundles)", }, pages: { type: Array, value() { const pages = [ { name: "Graphics", route: "graphics", icon: "visibility", }, { name: "Mixer", route: "mixer", icon: "av:volume-up", }, { name: "Assets", route: "assets", icon: "file-upload", }, ]; // For the time being, the "Settings" button is only relevant // when login security is enabled. if (window.ncgConfig.login?.enabled) { pages.push({ name: "Settings", route: "settings", icon: "settings", }); } return pages; }, }, }; } override ready(): void { super.ready(); // Images are stored as data URIs so that they can be displayed even with no connection to the server let FAIL_URI: string; let SUCCESS_URI: string; let notified = false; getImageDataURI("img/notifications/standard/fail.png", (err, result) => { /* istanbul ignore if: hard-to-hit error */ if (err) { console.error(err); } else { FAIL_URI = result!.data; } }); getImageDataURI("img/notifications/standard/success.png", (err, result) => { /* istanbul ignore if: hard-to-hit error */ if (err) { console.error(err); } else { SUCCESS_URI = result!.data; } }); window.socket.on("protocol_error", (err) => { /* istanbul ignore next: coverage is buggy here */ if (err.type === "UnauthorizedError") { window.location.href = `/authError?code=${err.code}&message=${err.message}`; } else { console.error("Unhandled socket error:", err); this.$.mainToast.show("Unhandled socket error!"); } }); window.socket.on("disconnect", () => { this.$.mainToast.show("Lost connection to NodeCG server!"); notified = false; this.disconnected = true; }); window.socket.io.on("reconnect_attempt", (attempts) => { if (!this.$.reconnectToast.opened) { this.$.reconnectToast.open(); } if (attempts >= 3 && !notified) { notified = true; notify("Disconnected", { body: "The dashboard has lost connection with NodeCG.", icon: FAIL_URI, tag: "disconnect", }); } }); window.socket.io.on("reconnect", (attempts) => { this.$.mainToast.show("Reconnected to NodeCG server!"); this.$.reconnectToast.hide(); this.disconnected = false; if (attempts >= 3) { notify("Reconnected", { body: `Successfully reconnected on attempt # ${attempts}`, icon: SUCCESS_URI, tag: "reconnect", }); } }); window.socket.io.on("reconnect_failed", () => { this.$.mainToast.show("Failed to reconnect to NodeCG server!"); notify("Reconnection Failed", { body: "Could not reconnect to NodeCG after the maximum number of attempts.", icon: FAIL_URI, tag: "reconnect_failed", }); }); } override connectedCallback(): void { super.connectedCallback(); // If the default workspace is hidden (due to it having no panels), // show the next workspace by default. if ( this.route.path === "" && window.__renderData__.workspaces[0]!.route !== "" ) { window.location.hash = window.__renderData__.workspaces[0]!.route; } if (!this.routeData) { this.routeData = {}; } if (!this.routeData.page) { this.set("routeData.page", ""); } this._fixTabs(); } /* istanbul ignore next: can't cover since it navigates the page */ logout() { window.location.href = "/logout"; } closeDrawer() { this.$.drawer.close(); } _smallScreenChanged(newVal: boolean) { if (!newVal) { this.closeDrawer(); } } _equal(a: any, b: any) { return a === b; } _selectRoute(e: any) { window.location.hash = e.target.closest("paper-tab").route; } _routeChanged() { this._fixTabs(); this._fixPathDebounce = Debouncer.debounce( this._fixPathDebounce, timeOut.after(100), this._fixPath.bind(this), ); } _fixTabs() { // For some reason, our paper-tabs elements need a little help // to know when the route has changed and when they should deselect their tabs. const tabs = this.shadowRoot!.querySelectorAll("paper-tabs"); if (tabs) { tabs.forEach((tabSet) => { if (tabSet.selected !== this.route.path) { tabSet.selected = this.route.path; } }); } } _fixPath() { // If the current hash points to a route that doesn't exist, (such as // after a refresh which removed a workspace), default to the first workspace. if (!this.$.pages.selectedItem) { window.location.hash = window.__renderData__.workspaces[0]!.route; } } _computeDialogs(bundles: NodeCG.Bundle[]) { const dialogs: any[] = []; bundles.forEach((bundle) => { bundle.dashboard.panels.forEach((panel) => { if (panel.dialog) { dialogs.push(panel); } }); }); return dialogs; } _falsey(value: any) { return !value; } _calcButtonClass(buttonType: string) { return buttonType === "confirm" ? "nodecg-accept" : "nodecg-reject"; } } function getImageDataURI( url: string, cb: ( error: NodeJS.ErrnoException | undefined, result?: { image: HTMLImageElement; data: string }, ) => void, ) { let data; let canvas; let ctx; const img = new Image(); img.onload = function () { // Create the canvas element. canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; // Get '2d' context and draw the image. ctx = canvas.getContext("2d"); if (!ctx) { cb(new Error("Could not create canvas context")); return; } ctx.drawImage(img, 0, 0); // Get canvas data URL try { data = canvas.toDataURL(); cb(undefined, { image: img, data, }); } catch (e: any) { /* istanbul ignore next: hard-to-test error */ cb(e); } canvas.remove(); }; // Load image URL. try { img.src = url; } catch (e: any) { /* istanbul ignore next: hard-to-test error */ cb(e); } } function notify( title: string, options: { body?: string; icon?: string; tag?: string } = {}, ) { // Let's check if the browser supports notifications if (!("Notification" in window)) { return; } // Let's check if the user is okay to get some notification. // Otherwise, we need to ask the user for permission. // Note, Chrome does not implement the permission static property. // So we have to check for NOT 'denied' instead of 'default'. if (window.Notification.permission === "granted") { // If it's okay let's create a notification const notification = new window.Notification(title, options); setTimeout(() => { notification.close(); }, 5000); } else if (window.Notification.permission !== "denied") { void window.Notification.requestPermission((permission) => { // If the user is okay, let's create a notification if (permission === "granted") { const notification = new window.Notification(title, options); setTimeout( (n) => { n.close(); }, 5000, notification, ); } }); } // At last, if the user already denied any notification, and you // want to be respectful there is no need to bother them any more. } customElements.define("ncg-dashboard", NcgDashboard);