Chaturbate MULTI-CAM Viewer

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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