Chaturbate MULTI-CAM Viewer

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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();
});