Sleazy Fork is available in English.
Adds a new tab to Chaturbate, and allows you to watch multiple webcams at once
// ==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();
});