Chaturbate MULTI-CAM Viewer

Adds a new tab to Chaturbate, and allows you to watch multiple webcams at once

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name          Chaturbate MULTI-CAM Viewer
// @namespace     Chaturbate MULTI-CAM Viewer by VVhat
// @description   Adds a new tab to Chaturbate, and allows you to watch multiple webcams at once
// @version       3.2.2
// @match         https://chaturbate.com/
// @match         https://chaturbate.com/#*
// @match         https://chaturbate.com/*-cams/*
// @include       https://*.chaturbate.com/
// @include       https://*.chaturbate.com/#*
// @include       https://*.chaturbate.com/*-cams/*
// @exclude       http://serve.ads.chaturbate.com/*
// @require       https://code.jquery.com/jquery-2.2.4.min.js
// @grant         unsafeWindow
// @grant         GM_registerMenuCommand
// @grant         GM_openInTab
// @grant         GM_getValue
// @grant         GM_setValue
// @license       MIT
// ==/UserScript==

function init() {
  if (window.top != window.self)
    return;

  if (cloneInto) {
    const insideGM = new gm();
    const outsideGM = createObjectIn(unsafeWindow, { defineAs: "gm" });

    Object.entries(insideGM).forEach(([ key, value ]) => {
      try {
        if (typeof value == "function")
          exportFunction(value, outsideGM, { defineAs: key });
      }
      catch (e) {}
    });
  }
  else
    unsafeWindow.gm = new gm;

  const script = document.createElement("script");
  script.textContent = "(" + main.toString() + ")();";
  document.body.appendChild(script);
}

//----------------------------------------
function gm() {
  const self = this;

  this.STREAMS_KEY_NAME = "chaturbate_streams";
  this.PINNED_STREAMS_KEY_NAME = "chaturbate_pinned_streams";
  this.LAYOUT_KEY_NAME = "chaturbate_layout";
  this.CONFIG_KEY_NAME = "chaturbate_config";

  //----------------------------------------
  this.loadSettings = () => {
    setTimeout(() => {
      const streams = GM_getValue(self.STREAMS_KEY_NAME) || "[]";
      const pinned = GM_getValue(self.PINNED_STREAMS_KEY_NAME) || "[]";
      const layout = GM_getValue(self.LAYOUT_KEY_NAME) || 2;
      const config = GM_getValue(self.CONFIG_KEY_NAME) || "{}";

      const adder = (streams, pinned, layout, config) => {
        streams.unshift(... pinned);
        streams = [ ... new Set(streams) ];

        streams.forEach(username => {
          viewer.streams.push(new Stream(username));
        });

        viewer.pinnedStreams = pinned;
        viewer.layoutId = layout;
        viewer.config = config;

        viewer.updateLayout(layout);
        viewer.loadConfigMenu();

        if (location.hash == "#live")
          viewer.show();
      }

      const script = document.createElement("script");
      script.textContent = `(${adder.toString()})(${streams}, ${pinned}, ${layout}, ${config});`;
      document.body.appendChild(script);
    }, 0);
  }

  //----------------------------------------
  this.saveSettings = () => {
    self.saveStreams();
    self.savePinnedStreams();
    self.saveLayout();
    self.saveConfig();
  }
 
  //----------------------------------------
  this.saveStreams = () => {
    setTimeout(() => {
      const data = JSON.stringify(unsafeWindow.jQuery.map(unsafeWindow.viewer.streams, o => o.username));
      GM_setValue(self.STREAMS_KEY_NAME, data);
    }, 0);
  }
 
  //----------------------------------------
  this.savePinnedStreams = () => {
    setTimeout(() => {
      const data = JSON.stringify(unsafeWindow.viewer.pinnedStreams);
      GM_setValue(self.PINNED_STREAMS_KEY_NAME, data);
    }, 0);
  }
 
  //----------------------------------------
  this.saveLayout = () => {
    setTimeout(() => {
      GM_setValue(self.LAYOUT_KEY_NAME, unsafeWindow.viewer.layoutId);
    }, 0);
  }
 
  //----------------------------------------
  this.saveConfig = () => {
    setTimeout(() => {
      const data = JSON.stringify(unsafeWindow.viewer.config);
      GM_setValue(self.CONFIG_KEY_NAME, data);
    }, 0);
  }

  return self;
}

//----------------------------------------
function main() {
  if (typeof jQuery == "undefined")
    return;

  $(function() {
    const exports = [ "websiteHostName", "toHtml", "getKey", "Stream", "viewer" ];

    const websiteHostName = location.protocol + "//" + location.host + "/";
    const toHtml = (data, template) => template.replace(/#(?:\{|%7B)(.*?)(?:\}|%7D)/g, (m, p1) => data[p1] || "");

    const getKey = e => {
      if (window.event) // IE
        return e.keyCode;
      else if (e.which) // Netscape/Firefox/Opera
        return e.which;
    }

    //----------------------------------------
    const Stream = function(username) {
      const user = username.replaceAll("/", "");

      this.username = user;
      this.href = websiteHostName + user;
      this.src = websiteHostName + "embed/" + user + "/?join_overlay=1&room=" + user;
    }

    //----------------------------------------
    const viewer = new (function() {
      const self = this;

      this.streams = [];
      this.pinnedStreams = [];
      this.layoutId = 2;
      this.config = {};
      this.loaded = false;
      this.isNewChaturbateTheme = false;
      this.loadSettings = gm.loadSettings;
      this.saveSettings = gm.saveSettings;
      this.saveStreams = gm.saveStreams;
      this.savePinnedStreams = gm.savePinnedStreams;
      this.saveLayout = gm.saveLayout;
      this.saveConfig = gm.saveConfig;

      //----------------------------------------
      this.init = () => {
        self.isNewChaturbateTheme = $(".HeaderNavBar").length > 0;

        //----------------------------------------
        const css = `#multi-cam--banner{display:flex;gap:1rem;margin:3px 32px;padding:4px;border-radius:6px;width:calc(100% - 64px);color:#bdbdbd}#multi-cam--banner img{background:#fff;padding:1px;border-radius:2px}#multi-cam--banner .config-toggle img{cursor:pointer}#multi-cam--banner .config-main.expanded .config-menu{display:flex}#multi-cam--banner .config-main .config-menu{display:none;flex-direction:column;gap:.5rem;margin-top:4px;padding:8px;border:1px solid #6a7989;border-radius:6px;background:#0c6a93;color:#fff}#multi-cam--banner .config-main .config-menu .title{margin-bottom:4px;font-size:1.6rem}#multi-cam--banner .config-main .config-menu label{cursor:pointer}#multi-cam--banner .config-main .config-menu input[type=checkbox]{box-shadow:inset 0 0 3px 0px #fff;cursor:pointer}#main .content li.cams{cursor:pointer}#main.multi-cam--enabled .content,#main.multi-cam--enabled .homepageFilterPanel{display:none !important}#main.multi-cam--enabled #multi-cam--main{display:block}#main #multi-cam--main{display:none;width:100%;height:auto}body.darkmode #main #multi-cam--main{background:#181818}#main #multi-cam--main #multi-cam--controls{margin:2px;padding:3px;border:1px solid #ccc}body.darkmode #main #multi-cam--main #multi-cam--controls{background:#2a2a2a;color:#fff;border:1px solid #000}body.darkmode #main #multi-cam--main #multi-cam--controls input{background:#606060;color:#efefef}#main #multi-cam--main #multi-cam--controls input[type=button]{cursor:pointer}#main #multi-cam--main #multi-cam--controls .layouts{display:inline-block}#main #multi-cam--main #multi-cam--controls .layouts .active{background:#fff;color:#dc5500;border:1px solid #000}body.darkmode #main #multi-cam--main #multi-cam--controls .layouts .active{background:#17202a;color:#68b5f0;border:1px solid #464646}#main #multi-cam--main #multi-cam--stream-list{display:flex;flex-wrap:wrap;gap:2px;margin:0;padding:0}#main #multi-cam--main #multi-cam--stream-list li,#main #multi-cam--main #multi-cam--stream-list.layout-1 li{width:500px;height:470px}#main #multi-cam--main #multi-cam--stream-list.layout-2 li{width:calc(33% - 1px);min-width:450px;height:411px}#main #multi-cam--main #multi-cam--stream-list.layout-2 iframe{top:-52px}#main #multi-cam--main #multi-cam--stream-list.layout-3 li{width:1030px;height:544px}#main #multi-cam--main #multi-cam--stream-list li{display:inline-block;margin:0;padding:0;overflow:hidden}#main #multi-cam--main #multi-cam--stream-list iframe{position:relative;width:1030px;height:528px;margin:0;padding:0;top:0;border:none}#main #multi-cam--main #multi-cam--stream-list .controls{position:relative;display:flex;gap:4px;opacity:.7;margin-left:2px;width:fit-content;border-bottom-right-radius:8px;z-index:99}#main #multi-cam--main #multi-cam--stream-list .controls:hover{background:rgba(167,167,167,.1882352941);opacity:1;backdrop-filter:blur(4px)}#main #multi-cam--main #multi-cam--stream-list .controls .remove{display:inline;position:relative;top:2px;left:1px;float:left;cursor:pointer}#main #multi-cam--main #multi-cam--stream-list .controls .open img{display:inline;position:relative;top:2px}#main #multi-cam--main #multi-cam--stream-list .controls .move{position:relative;color:#000;cursor:pointer}#main #multi-cam--main #multi-cam--stream-list .controls .pin{display:inline-block;position:relative;margin-top:-1px;height:16px;background-size:20px}`;

        // https://sass-lang.com/playground/
        /*
          #multi-cam--banner {
            display: flex;
            gap: 1rem;
            margin: 3px 32px;
            padding: 4px;
            border-radius: 6px;
            width: calc(100% - 64px);
            color: #BDBDBD;

            img {
              background: #FFFFFF;
              padding: 1px;
              border-radius: 2px;
            }

            .config- {
              &toggle img {
                cursor: pointer;
              }

              &main {
                &.expanded .config-menu {
                  display: flex;
                }

                .config-menu {
                  display: none;
                  flex-direction: column;
                  gap: 0.5rem;
                  margin-top: 4px;
                  padding: 8px;
                  border: 1px solid #6A7989;
                  border-radius: 6px;
                  background: #0C6A93;
                  color: #FFFFFF;

                  .title {
                    margin-bottom: 4px;
                    font-size: 1.6rem;
                  }

                  label {
                    cursor: pointer;
                  }

                  input[type=checkbox] {
                    box-shadow: inset 0 0 3px 0px #FFFFFF;
                    cursor: pointer;
                  }
                }
              }
            }
          }

          #main {
            .content li.cams {
              cursor: pointer;
            }

            &.multi-cam--enabled {
              .content,
              .homepageFilterPanel {
                display: none !important;
              }

              #multi-cam--main {
                display: block;
              }
            }

            #multi-cam--main {
              display: none;
              width: 100%;
              height: auto;

              body.darkmode & {
                background: #181818;
              }

              #multi-cam-- {
                &controls {
                  margin: 2px;
                  padding: 3px;
                  border: 1px solid #CCCCCC;

                  body.darkmode & {
                    background: #2A2A2A;
                    color: #FFFFFF;
                    border: 1px solid #000000;

                    input {
                      background: #606060;
                      color: #EFEFEF;
                    }
                  }

                  input[type=button] {
                    cursor: pointer;
                  }

                  .layouts {
                    display: inline-block;

                    .active {
                      background: #FFFFFF;
                      color: #DC5500;
                      border: 1px solid #000000;

                      body.darkmode & {
                        background: #17202A;
                        color: #68B5F0;
                        border: 1px solid #464646;
                      }
                    }
                  }
                }

                &stream-list {
                  display: flex;
                  flex-wrap: wrap;
                  gap: 2px;
                  margin: 0;
                  padding: 0;

                  &,
                  &.layout-1 {
                    li {
                      width: 500px;
                      height: 470px;
                    }
                  }

                  &.layout-2 {
                    li {
                      width: calc(33% - 1px);
                      min-width: 450px;
                      height: 411px;
                    }

                    iframe {
                      top: -52px;
                    }
                  }

                  &.layout-3 {
                    li {
                      width: 1030px;
                      height: 544px;
                    }
                  }

                  li {
                    display: inline-block;
                    margin: 0;
                    padding: 0;
                    overflow: hidden;
                  }

                  iframe {
                    position: relative;
                    width: 1030px;
                    height: 528px;
                    margin: 0;
                    padding: 0;
                    top: 0;
                    border: none;
                  }

                  .controls {
                    position: relative;
                    display: flex;
                    gap: 4px;
                    opacity: 0.7;
                    margin-left: 2px;
                    width: fit-content;
                    border-bottom-right-radius: 8px;
                    z-index: 99;

                    &:hover {
                      background: #A7A7A730;
                      opacity: 1;
                      backdrop-filter: blur(4px);
                    }

                    .remove {
                      display: inline;
                      position: relative;
                      top: 2px;
                      left: 1px;
                      float: left;
                      cursor: pointer;
                    }

                    .open img {
                      display: inline;
                      position: relative;
                      top: 2px;
                    }

                    .move {
                      position: relative;
                      color: #000000;
                      cursor: pointer;
                    }

                    .pin {
                      display: inline-block;
                      position: relative;
                      margin-top: -1px;
                      height: 16px;
                      background-size: 20px;
                    }
                  }
                }
              }
            }
          }
        */

        $(document.head).append($("<style>")
          .attr("type", "text/css")
          .text(css));

        //----------------------------------------
        const banner = `
          <div id="multi-cam--banner">
            <span class="notice">Use the <img src="https://static-assets.highwebmedia.com/images/cam.svg" align="absmiddle"> icon to add streams to the "MULTI-CAM" tab</span>
            <div class="config-main">
              <div class="config-toggle">
                <img src="https://web.static.mmcdn.com/images/option_cog_light.svg" title="Multi-cam viewer settings" onclick="viewer.toggleConfigMenu();">
              </div>
              <div class="config-menu">
                <div class="title">Multi-cam viewer settings</div>
                <label>
                  <input type="checkbox" name="autosave" onclick="viewer.toggleConfigCheckbox('autosave');"></input>
                  <span>Autosave streams & layout</span>
                </label>
              </div>
            </div>
          </div>`;

        $(".content").first().prepend(banner);

        //----------------------------------------
        const tab = `
          <div id="multi-cam--main">
            <div id="multi-cam--controls">
              Username: <input type="text" name="stream-name-input" id="stream-name-input" onkeyup="if (getKey(event) == 13) viewer.add();" >
              <input type="button" value="Add" onclick="viewer.addStreamFromInput();">
              <input type="button" value="Remove All" onclick="viewer.removeAllStreams();">
              <input type="button" value="Remove Offlines" onclick="viewer.removeOfflineStreams();">
              <input type="button" value="Save" onclick="viewer.saveSettings();">
              <div class="layouts">
                [ Layout: 
                <input type="button" value="Compact" onclick="viewer.updateLayout(1);" id="layout-1">
                <input type="button" value="Semi-Compact" onclick="viewer.updateLayout(2);" id="layout-2">
                <input type="button" value="Full" onclick="viewer.updateLayout(3);" id="layout-3">
                ]
              </div>
            </div>
            <ul id="multi-cam--stream-list"></ul>
          </div>`;

        $("#main .content").after(tab);

        //----------------------------------------
        if (self.isNewChaturbateTheme) {
          const primaryButton = $("<a>")
            .addClass("HeaderNavBar__link")
            .attr("href", "javascript:viewer.show();")
            .append($("<div>")
              .addClass("HeaderNavBar__link-text type--medium type--smpx")
              .text("MULTI-CAM"));

          $(".HeaderNavBar").append(primaryButton);
        }
        else {
          const primaryButton = $("<li>")
            .append($("<a>")
              .attr("href", "javascript:viewer.show();")
              .text("MULTI-CAM"));

          $(".broadcast-yourself").first().before(primaryButton);
        }

        //----------------------------------------
        self.loadSettings();
        self.bindStreamCards();
        self.bindNavButtons();
        self.updateStreams();
      }

      //----------------------------------------
      this.show = () => {
        $("#main").addClass("multi-cam--enabled");
        location.hash = "#live";

        if (self.loaded == false) {
          self.loaded = true;
          self.updateStreams();
        }
      }

      //----------------------------------------
      this.hide = () => {
        $("#main").removeClass("multi-cam--enabled");
        location.hash = "#tab";
      }

      //----------------------------------------
      this.loadConfigMenu = () => {
        Object.entries(self.config).forEach(([ key, value ]) => {
          $("#multi-cam--banner .config-menu [name=" + key + "]").prop("checked", value);
        });
        
        self.loadConfigSettings();
      }

      //----------------------------------------
      this.loadConfigSettings = () => {
        // for any config that needs to init
      }

      //----------------------------------------
      this.bindStreamCards = () => {
        const handleAddStream = function(e) {
          const href = $(this.parentElement).closest("li").find("a").attr("href");

          $(this).text("Stream added to MULTI-CAM");
          self.addStream(href);
        }

        if (self.isNewChaturbateTheme) {
          const handleClick = e => $(e.target).is("a, img");

          const observerGrid = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
              mutation.addedNodes.forEach(node => {
                $("li.cams", node).click(handleAddStream);
                $(node).click(handleClick);
              });
            });
          });

          const observerRoot = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
              mutation.addedNodes.forEach(node => {
                if ($(node).is("ul.RoomCardGrid")) {
                  $("li.cams", node).click(handleAddStream);
                  $("li.RoomCard", node).click(handleClick);

                  observerGrid.observe(node, { childList: true });
                }
              });
            });
          });

          observerGrid.observe($("ul.RoomCardGrid")[0], { childList: true });
          observerRoot.observe($("#roomlist_root")[0], { childList: true, subtree: true });

          $("li.cams").click(handleAddStream);
          $("li.RoomCard").click(handleClick);
        }
        else {
          $("li.cams")
            .attr("title", "Add stream to MULTI-CAM")
            .live("click", handleAddStream);
        }
      }

      //----------------------------------------
      this.bindNavButtons = () => {
        if (self.isNewChaturbateTheme) {
          $(".HeaderNavBar a").click(function(e) {
            const page = location.href.split("#")[0];
            const target = $(this).attr("href");

            if (page != target)
              return true;

            self.hide();
            return false;
          });
        }
        else {
          $("#nav li").click(function(e) {
            const page = location.href.split("#")[0];
            const target = location.origin + $(this).find("a").attr("href");

            if (page != target)
              return true;

            self.hide();
            return false;
          });
        }
      }

      //----------------------------------------
      this.toggleConfigMenu = () => {
        $("#multi-cam--banner .config-main").toggleClass("expanded");
      }

      //----------------------------------------
      this.toggleConfigCheckbox = option => {
        self.config[option] = !self.config[option];
        self.saveConfig();
        self.loadConfigSettings();
      }

      //----------------------------------------
      //----------------------------------------
      //----------------------------------------
      this.addStream = username => {
        self.streams.push(new Stream(username));
        self.loaded = false;

        if (self.config.autosave)
          self.saveStreams();
      }

      //----------------------------------------
      this.addStreamFromInput = () => {
        const username = $("#stream-name-input").val();

        self.streams.push(new Stream(username));
        $("#stream-name-input").val("");
        self.updateStreams();
      }

      //----------------------------------------
      this.removeAllStreams = () => {
        self.streams = [];
        self.updateStreams();
      }

      //----------------------------------------
      this.removeOfflineStreams = () => {
        const handleRemoveStream = node => {
          const username = node.closest("li").id;
          self.removeStream(username, node);
        }

        // Semi-loaded streams change their src every ~0.5s
        const checkStreamOffline = (player, node) => {
          const src = player.attr("src");

          setTimeout(() => {
            if (src != player.attr("src"))
              handleRemoveStream(node);
          }, 1000);
        }

        /*
          1. Refused to connect, triggers the catch block
          2. Connecting, this.contentWindow.document.body doesn't exist, or #main or .BaseRoomContents has no children, or .playerTitleBar exists & has no grandchildren
          3. Connected but no content, neither chat-player exist
          4. Connected but stream is private, .roomStatusNotifier__header text will be non-empty & not "Offline"
          5. Connected but only showing preview image that updates a few times a second, img#chat-player exists, src changes
          6. Connected, video#chat-player_html5_api[src] exists
          7. Previously connected but now offline, video#chat-player_html5_api exists but has no src
        */
        $("#multi-cam--stream-list iframe").each(function() {
          try {
            const doc = $(this.contentWindow.document);
            // iframe is loading
            const isFrameLoading = !this.contentWindow.document.body;
            // Page content is loading
            const isPageLoading = $("#main, .BaseRoomContents", doc).children().length == 0 || ($(".playerTitleBar", doc).length > 0 && $(".playerTitleBar", doc).children().children().length == 0);
            // Stream is private/hidden/away
            const isHidden = $(".roomStatusNotifier__header", doc).text().length > 0 && $(".roomStatusNotifier__header", doc).text() != "Offline";
            // A player exists
            const hasPlayer = $("#chat-player, #chat-player_html5_api", doc).length > 0;
            // A video exists but is not playing
            const isStreamEnded = $("#chat-player_html5_api", doc).length > 0 && $("#chat-player_html5_api[src]", doc).length == 0;
            // An image preview exists
            const hasImgPreview = $("img#chat-player", doc).length > 0;

            if (isFrameLoading || isPageLoading || isHidden || (hasPlayer && !isStreamEnded))
              return;

            if (hasImgPreview)
              checkStreamOffline($("img#chat-player", doc), this);
            else
              handleRemoveStream(this);
          }
          catch (e) {
            handleRemoveStream(this);
          }
        });
      }

      //----------------------------------------
      //----------------------------------------
      //----------------------------------------
      this.removeStream = (username, node) => {
        const name = username.toLowerCase();
        self.streams = self.streams.filter(o => o.username.toLowerCase() != name);
        $(node).parent().remove();

        self.updateStreams();
      }

      //----------------------------------------
      this.shiftStream = (username, shift) => {
        const index = self.streams.map(o => o.username).indexOf(username);
        const newPosition = index + shift;
        const newIndex = newPosition < 0 ? self.streams.length : newPosition >= self.streams.length ? 0 : newPosition;

        self.streams.splice(newIndex, 0, self.streams.splice(index, 1)[0]);
        self.updateStreams();
      }

      //----------------------------------------
      this.togglePinnedStream = (username, node) => {
        const index = self.pinnedStreams.indexOf(username);

        // Add
        if (index == -1) {
          self.pinnedStreams.push(username);

          $(node)
            .addClass("icon_following")
            .removeClass("icon_not_following")
            .attr("title", "Un-favorite");
        }
        // Remove
        else {
          self.pinnedStreams.splice(index, 1);

          $(node)
            .addClass("icon_not_following")
            .removeClass("icon_following")
            .attr("title", "Favorite");
        }

        if (self.config.autosave)
          self.savePinnedStreams();
      }

      //----------------------------------------
      //----------------------------------------
      //----------------------------------------
      const listTemplate = `
        <li id="#{username}">
          <div class="controls">
            <div class="remove" title="Remove" onclick="viewer.removeStream('#{username}',this)">❌</div>
            <a class="open" target="_blank" href="#{href}" title="Open">
              <img src="https://static-assets.highwebmedia.com/images/cam.svg">
            </a>
            <div class="move" title="Move left" onclick="viewer.shiftStream('#{username}',-1)">←</div>
            <div class="move" title="Move right" onclick="viewer.shiftStream('#{username}',1)">→</div>
            <div class="pin icon_not_following" title="Favorite" onclick="viewer.togglePinnedStream('#{username}',this)"></div>
          </div>
          <iframe src="#{src}"></iframe>
        </li>`;

      this.updateStreams = () => {
        if (!$("#main").hasClass("multi-cam--enabled"))
          return;

        const streamsList = $("#multi-cam--stream-list");
        const streamNames = self.streams.map(o => o.username);

        self.streams.forEach(stream => {
          // Add new streams
          if ($("li#" + stream.username, streamsList).length == 0)
            streamsList.append(toHtml(stream, listTemplate));
        });

        $("li", streamsList).each(function() {
          const index = streamNames.indexOf(this.id);

          // Remove streams
          if (index == -1)
            $(this).remove();

          // Reorder
          else
            $(this).css("order", index.toString());

          // Toggle pinned state
          if (self.pinnedStreams.includes(this.id)) {
            $(".pin", this)
              .addClass("icon_following")
              .removeClass("icon_not_following")
              .attr("title", "Un-favorite");
          }
        });

        self.updateLayout(self.layoutId);

        if (self.config.autosave)
          self.saveStreams();
      }

      //----------------------------------------
      this.updateLayout = id => {
        self.layoutId = id;

        $("#multi-cam--controls .layouts .active").removeClass("active");
        $("#multi-cam--controls #layout-" + id).addClass("active");
        $("#multi-cam--stream-list")
          .removeClass()
          .addClass("layout-" + id);

        if (self.config.autosave)
          self.saveLayout();
      }
    })

    //----------------------------------------
    exports.forEach(o => void(window[o] = eval(o)));
    window.viewer.init();
  });
}

//----------------------------------------
$(function() {
  init();
});