NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Twitch Chat over Video
// @name:es Chat Twitch sobre Video
// @namespace http://tampermonkey.net/
// @version 2.0.4
// @description Moves the chat to the video as an overlay.
// @description:es Mueve el chat de twitch sobre el video.
// @copyright 2022, enzo418 (https://openuserjs.org/users/enzo418)
// @downloadURL https://openuserjs.org/install/enzo418/Twitch_Chat_over_Video.user.js
// @updateURL https://openuserjs.org/meta/enzo418/Twitch_Chat_over_Video.meta.js
// @author enzo418
// @match https://www.twitch.tv/*
// @grant none
// @license Apache-2.0
// ==/UserScript==
// ==OpenUserJS==
// @author enzo418
// ==/OpenUserJS==
/* jshint esversion: 8 */
(async function () {
'use strict';
/*
Updates:
2.0.4:
- Clear UserEvents when the window lost focus
2.0.3:
- Changed the selector for the vod chat container.
2.0.2:
- Fixed chat input wasn't working.
- Now it moves the whole chat container, including channel points, configuration and emoji menu
2.0.1:
- Fixed toggle icon isn't showing
2.0.0:
- Fixed scroll not working
- Fixed chat jumps when hovering the mouse over it
- Completely reformed the code
1.1.12:
- Option to delete a message in the moderator menu
1.1.11:
- Fixed scroll issue when banning
- Default position of modmenu is right above the username
1.1.10:
- Fixed create multiple times the overlay
- Replaced text of "Resume auto scroll" with a circle
1.1.9:
- Mod menu added, you can ban, timeout and un-ban a user in full-screen mode
- To quickly timeout a user for 60 seconds, hold down Control and right-click the name
1.1.8:
- When you click on a user in the chat it shows the built-in twitch menu to manage that user, such as timeout and ban.
- Now you don't need to reload the page in order to see the chat in full-screen when changing between channels or steam/vod
1.1.7:
- Fix: input chat not displayed due to a class error again and auto scroll fixed
1.1.6:
- Fix: input chat not displayed due to a class error
1.1.5:
- Feature: Show the chat input when the mouse enters the chat
1.1.4:
- Fixed white box icon due to new twitch css rules.
1.1.3:
- Fixed a issue realted to last BTTV Fix.
1.1.2:
- Fixed a issue with BTTV pinned messages.
- Now the overlay can be resized moving its corners (top and right).
- Now can change the opacity of the overlay pressing the ALT key and rotating the mouse wheel.
1.1.1:
- Fixed a bug where the chat input is not displayed
1.1.0:
- Added scroll in chat overlay.
- Added support to VODs.
1.0.0:
- Initial release
*/
/* Details: https://openuserjs.org/scripts/enzo418/Twitch_Chat_over_Video */
function query(str, source = document) {
return source.querySelector(str);
}
function createElementFromHTML(htmlString) {
var div = document.createElement('div');
div.innerHTML = htmlString.trim();
return div.firstChild;
}
function insertStyle(style) {
var newStyle = document.createElement("style");
newStyle.innerHTML = style;
document.getElementsByTagName("head")[0].appendChild(newStyle);
}
/**
* Solution to BTTV pin messages issue => Mutator observer on the pin container to
* remove all its children when the overlay is toggled (show/hide).
*
* BTTV module uses a mutator on the chat so we can't really bypass that.
**/
function SolutionBTTVPinIssue () {
var target = document.querySelector("#bttv-pin-container");
var observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
// remove every "#bttv-pinned-highlight"
mutation.addedNodes.forEach(node => {
node.remove();
});
});
});
if (target) {
observer.observe(target, { childList: true });
// Stop the mutator after a while because we not longer need it.
setTimeout(() => observer.disconnect(), 1000);
}
}
function selector (selector) {
var val = null;
let isVOD = window.location.pathname.indexOf("video") >= 0;
switch (selector) {
case "playerContainer":
val = 'div[data-test-selector="video-player__video-container"]'; // Video Player
break;
case "videoPlayer":
val = 'div[data-a-target="video-player"]'; //
break;
case "playerLayoutContainer":
val = '.persistent-player'; // Video Player + Video layout
break;
case "chatColumnContainer":
val = 'div[data-a-target="right-column-chat-bar"]'; // chat scrollable
break;
case "chatContainer":
val = isVOD ? '.video-chat ul' : 'div[data-test-selector="chat-scrollable-area__message-container"]'; // chat + input
break;
case "chatInputAreaWithoutOverlay":
val = ".chat-input"; // chat input (before overlay) - Children of the container with follower/sub restrictions
break;
case "playerLayoutControls":
val = '.video-ref .player-controls__right-control-group'; //
break;
case "fullScreenButton":
val = 'button[data-a-target="player-fullscreen-button"]'; // full screen button
break;
case "chatUserCard":
val = ".chat-room__viewer-card"; //
break;
case "realChatInput":
val = "[data-a-target='chat-input']";
break;
default:
val = "";
break;
}
return val;
}
var isVOD = window.location.pathname.indexOf("video") >= 0;
// wait until the chat loaded
while ((isVOD || query(selector("chatContainer")) === null)
&& query(selector("fullScreenButton")) === null) {
await new Promise(resolve => setTimeout(resolve, 100));
}
var checkExist = setInterval(function () {
if (query(selector("chatContainer")) !== null
&& query(selector("fullScreenButton")) !== null) {
clearInterval(checkExist);
main();
}
}, 100);
const getOverlayTemplate = () => `
<div id="toggle-chat-overlay-fs" class="tw-button-icon--overlay preset-0">
<button class="tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon tw-button-icon--overlay tw-core-button tw-core-button--overlay tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative" aria-label="Toggle Chat Overlay">
<span class="tw-button-icon__icon">
<div style="width: 2rem; height: 2rem;">
<div class="tw-align-items-center tw-full-width tw-icon tw-icon--fill tw-inline-flex">
<div class="tw-aspect tw-aspect--align-top">
<div class="tw-aspect__spacer"></div>
<!--- Original SVG author: Becris -->
<svg style="overflow: hidden; width: 90%;" class="tw-icon__svg" width="100%" version="1.1" transform="scale(1)" viewBox="0 -80 500 580" style="overflow: hidden;height: auto;width: 100%;height: fit-content;"><path id="svg_3" d="m426.667,0.002l-375.467,0c-28.277,0 -51.2,22.923 -51.2,51.2l0,273.067c0,28.277 22.923,51.2 51.2,51.2l60.587,0l-9.284,83.456c-1.035,9.369 5.721,17.802 15.09,18.837c4.838,0.534 9.674,-1.023 13.292,-4.279l108.919,-98.014l186.863,0c28.277,0 51.2,-22.923 51.2,-51.2l0,-273.067c0,-28.277 -22.923,-51.2 -51.2,-51.2zm17.066,324.267c0,9.426 -7.641,17.067 -17.067,17.067l-193.416,0c-4.217,0.001 -8.284,1.564 -11.418,4.386l-80.452,72.414l6.434,-57.839c1.046,-9.367 -5.699,-17.809 -15.067,-18.856c-0.63,-0.07 -1.263,-0.106 -1.897,-0.105l-79.65,0c-9.426,0 -17.067,-7.641 -17.067,-17.067l0,-273.067c0,-9.426 7.641,-17.067 17.067,-17.067l375.467,0c9.426,0 17.067,7.641 17.067,17.067l0,273.067l-0.001,0z" fill="#fff"/>
<rect id="svg_chat_input" height="122" width="369" y="200.4335" x="56.4335" stroke-width="0" stroke="#000" fill="#fff"/>
<line id="svg_overlay_hide" stroke="#ffffff" stroke-linecap="undefined" stroke-linejoin="undefined" id="svg_6" y2="348.43349" x2="448.43349" y1="26.43349" x1="30.4335" stroke-width="28.5" fill="none"/>
</svg>
</div>
</div>
</div>
</span>
</button>
</div>
`;
const getAutoScrollButtonTemplate = (style, preset) => `
${(preset === 0 &&
`<button style="left: 0px;bottom: 0px;position: absolute;
background-color: rgb(0, 0, 0);z-index: 999999;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1">Resume auto scroll</button>`
) || ""}
${(preset === 1 &&
`<button style="background-color: rgb(10 1 1 / 34%);z-index: 999999;display: block;right: 0px;padding: 5px;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1">
<div clas="circle" style="content: '';width: 5px;height: 5px;border-radius: 5px;background-color: #8500ff;"></div>
</button>`
) || ""}
`;
const getModeratorMenuStyle = () => `#container-chat-moderator-menu { z-index: 9999999; position: absolute; top: 18px; background-color: #18181b; padding: 0.2rem; /* border: 1px #fff solid; */ text-align: center; text-transform: uppercase; display: flex; flex-direction: column; color: #fff; border-radius: 3px; } #container-chat-moderator-menu .close-button { position: absolute; top: 0; right: 0; font-size: small; font-weight: bolder; color: #f00; background-color: #0000007d; padding: 0 0.2rem; cursor: pointer; } #container-chat-moderator-menu .mod-timeout { display: flex; flex-direction: column; } #container-chat-moderator-menu .mod-timeout span { margin-right: 0.5rem; } #container-chat-moderator-menu .mod-timeout .container-timeout-options { display: flex; flex-direction: row; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time { margin-right: 0.5rem !important; border: 0 !important; padding: 0 0.2rem; border-radius: 3px; text-transform: none; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time:hover { background-color: #a7bdf1e0; color: black; } #container-chat-moderator-menu .mod-option-selectable { cursor: pointer; } #container-chat-moderator-menu .mod-option-selectable:hover { background-color: #1d4a71f7; } #container-chat-moderator-menu .option-ban.mod-option-selectable:hover { background-color: #7b1717; } #chat-moderator-menu > div { border-bottom: 0.1rem solid #070708; padding: 0.2rem 0; border-radius: 3px; }`;
const getModeratorMenuTemplate = () => `
<div id="container-chat-moderator-menu">
<div id="chat-moderator-menu">
<span class="close-button">x</span>
<!-- <div>un-timeout</div> -->
<div class="mod-timeout">
<span>timeout</span>
<div class="container-timeout-options">
<div data-timeout="60" class="timout-time mod-option-selectable">
1m
</div>
<div data-timeout="300" class="timout-time mod-option-selectable">
5m
</div>
<div data-timeout="600" class="timout-time mod-option-selectable">
10m
</div>
<div data-timeout="1800" class="timout-time mod-option-selectable">
30m
</div>
</div>
</div>
<div data-unban="true" class="mod-option-selectable">un-ban</div>
<div data-deletemessage="true" class="mod-option-selectable">delete message</div>
<div data-ban="true" class="mod-option-selectable option-ban">ban</div>
</div>
</div>`;
const getStyles = () => `
#overlay-fs {
position: absolute; display:none; z-index: 9999;
bottom: 10%; border-radius: 2px;right: 0 !important;
}
#overlay-fs #chat-container-fs {
color:white; height: 100%; overflow-y: scroll; overflow-x:hidden;
}
/** hide chat scrollbar */
#chat-container-fs::-webkit-scrollbar { display: none; }
#change-height-overlay-fs {
width: 100%; height: 17px; cursor: row-resize; position: absolute; top: 0; z-index: 100;
}
#change-width-overlay-fs {
width: 17px; height: 100%; cursor: col-resize; position: absolute; right: 0; z-index: 100;
}
#toggle-chat-overlay-fs.preset-0 #svg_chat_input, .preset-2 #svg_chat_input, .preset-0 #svg_overlay_hide, .preset-1 #svg_overlay_hide {
display: none;
}
#toggle-chat-overlay-fs.preset-1 #svg_chat_input {
display: block;
}
#toggle-chat-overlay-fs.preset-2 #svg_overlay_hide {
display: block;
}
/** this fixed the jumping thing */
div#chat-container-fs {
padding-bottom: 1rem;
}
`;
const getNullDivElement = () => `<div style="display:none"></div>`;
/**
* Abstract
**/
class ElementController {
constructor (elm) {
// if element is null or undefined use an empty div hidden so it doesn't break
this._element = elm || createElementFromHTML(getNullDivElement());
this.initialParent = this._element.parentNode;
}
set element (el) {
this._element = el;
this.initialParent = el.parentNode;
}
get element() {
return this._element;
}
hide () {
this.element.style.display = "none";
}
show () {
this.element.style.display = "block";
}
/**
* @param {double} width
**/
set width(width) {
this.element.style.width = width + "px";
}
/**
* @return {double} width
**/
get width() {
return parseFloat(this.element.style.width.replace("px", ""));
}
/**
* @param {double} height
**/
set height(height) {
this.element.style.height = height + "px";
}
/**
* @return {double} height
**/
get height() {
return parseFloat(this.element.style.height.replace("px", ""));
}
/**
* @param {double} opacity
**/
set opacity (opacity) {
this.element.style.background = `rgb(0 0 0 / ${opacity}%)`;
}
/**
* @return {double} opacity
**/
get opacity () {
let a = this.element.style.background;
return parseFloat(a.substring(a.indexOf("/ ") + 2, a.indexOf("%")));
}
/**
* @param {HTMLElement} opacity
**/
move (to) {
to.appendChild(this.element);
}
/**
* Moves the element to the initial parent
**/
moveToInitialParent () {
this.initialParent.appendChild(this.element);
}
/**
* Adds a event listener to the element
* @param {string} event
* @param {function} callback
* @param {boolean} useCapture
**/
on (event, callback, useCapture = false) {
this.element.addEventListener(event, callback.bind(this), useCapture);
}
/**
* Sets the element id
* @param {string} id
**/
set id(id) {
this.element.id = id;
this._id = "#"+id;
}
get id() {
return this.element.id;
}
get idSelector() {
return this._id;
}
setAsDraggable (condition_drag_element) {
var startX = 0,
startY = 0,
lastBottom = window.innerHeight * 5 / 100;
const maxY = window.innerHeight;
const minY = 0;
var maxX = window.innerWidth - this.element.getBoundingClientRect().width;
const minX = 0;
this.element.onmousedown = (e) => {
e = e || window.event;
if (condition_drag_element(e.target)) {
// recalculate bound
maxX = window.innerWidth - this.element.getBoundingClientRect().width;
e.preventDefault();
// get the mouse cursor position at startup:
startX = e.clientX;
startY = e.clientY;
document.onmouseup = () => {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
};
// call a function whenever the cursor moves:
document.onmousemove = (e) => {
e = e || window.event;
e.preventDefault();
// move Y
lastBottom += startY - e.clientY;
lastBottom = lastBottom < minY ? minY : (lastBottom > maxY ? maxY : lastBottom);
this.element.style.bottom = lastBottom + "px";
// move X
var left = this.element.offsetLeft - (startX - e.clientX);
left = left < minX ? minX : (left >= maxX ? maxX : left);
this.element.style.left = left + "px";
startX = e.clientX;
startY = e.clientY;
};
}
};
}
}
/**
* This class represents an Element that is able to change the height of another element.
**/
class ElementChangeHeight extends ElementController {
constructor(resizableElement, initialHeigth, id = "", heightChangeRate = 10, callback_onchange) {
super(document.createElement("div"));
this.id = id;
this.target = resizableElement;
this.initialHeigth = initialHeigth;
this.heightChangeRate = heightChangeRate;
this.callback_onchange = callback_onchange;
this.setEventsChangeHeight();
}
/**
* @param {Float32Array} heightChangeRate
**/
set heightChangeRate (heightChangeRate) {
this._heightChangeRate = heightChangeRate;
}
get heightChangeRate () {
return this._heightChangeRate;
}
/**
* @param {HTMLElement} target
**/
set target (target) {
this._target = target;
}
get target () {
return this._target;
}
/**
* @param {Float32Array} initialHeigth
**/
set initialHeigth (initialHeigth) {
this._initialHeigth = initialHeigth;
}
get initialHeigth () {
return this._initialHeigth;
}
setEventsChangeHeight() {
var startY = 0, heightChange = this.initialHeigth, height = 0;
this.element.onmousedown = (e) => {
e.preventDefault();
startY = e.clientY;
document.onmouseup = () => {
this.callback_onchange(height);
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
};
// call a function whenever the cursor moves:
document.onmousemove = (e) => {
e = e || window.event;
e.preventDefault();
heightChange += startY - e.clientY;
height = (this.heightChangeRate + heightChange);
this.target.style.height = height + "px";
this.initialHeigth = height;
startY = e.clientY;
};
};
}
}
/**
* This class represents an Element that is able to change the width of another element.
**/
class ElementChangeWidth extends ElementController {
constructor (resizableElement, initialWidth, id = "", widthChangeRate = 10, callback_onchange) {
super(document.createElement("div"));
this.id = id;
this.callback_onchange = callback_onchange;
this.setEventsChangeWidth(this.element, resizableElement, initialWidth, widthChangeRate);
}
setEventsChangeWidth (elmnt, target, initialWidth, widthChangeRate) {
var startX = 0, widthChange = initialWidth, width = 0;
elmnt.onmousedown = (e) => {
e.preventDefault();
startX = e.clientX;
document.onmouseup = () => {
this.callback_onchange(width);
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
};
// call a function whenever the cursor moves:
document.onmousemove = (e) => {
e = e || window.event;
e.preventDefault();
widthChange += e.clientX - startX;
width = (widthChangeRate + widthChange);
target.style.width = width + "px";
initialWidth = width;
startX = e.clientX;
};
};
}
}
class OverlayContainer extends ElementController {
constructor (opacity, width, height, callback_onchangeheight, callback_onchangewidth) {
super(document.createElement("div"));
this.id = "overlay-fs";
this.opacity = opacity;
this.width = width;
this.height = height;
this.changeHeight = new ElementChangeHeight(
this.element,
height,
"change-height-overlay-fs",
10,
callback_onchangeheight
);
this.changeWidth = new ElementChangeWidth(
this.element,
width,
"change-width-overlay-fs",
10,
callback_onchangewidth
);
this.changeHeight.move(this.element);
this.changeWidth.move(this.element);
this.setAsDraggable(
(el) => el.localName !== "textarea" // allow to select text from the input
&& el.localName !== "span" // allow to select text from the chat
&& el.id != this.changeHeight.id
&& el.id != this.changeWidth.id
);
}
moveToPlayer () {
// set the overlay to be a children of the main video container
var mainPlayer = query(selector("playerContainer"));
this.move(mainPlayer);
}
}
class ChatContainer extends ElementController {
/**
* @param {HTMLElement} element
* @param {BigInteger} opacity
* @param {UserEvents} userEvents
**/
constructor (element, opacity, userEvents) {
super(element);
this.opacity = opacity;
this.events = userEvents;
this.lastTimestamp = 0;
}
/**
* @param {HTMLElement} el
**/
set element(el) {
super.element = el;
// since the element of the controller can change set all
// the listeners/attributes in the setter of the element
this.id = "chat-container-fs";
this.on("scroll", this.onScroll);
}
get element() {
return super.element;
}
autoScrollTick () {
this.element.scrollTop = this.element.scrollHeight;
}
stopAutoScroll () {
clearInterval(this.interval);
this.interval = null;
this.buttonAutoScroll.show();
}
startAutoScroll () {
this.stopAutoScroll();
this.interval = setInterval(this.autoScrollTick.bind(this), 100);
this.events.mouseWheelEvent = false;
this.buttonAutoScroll.hide();
}
get scrollPosition () {
return this.element.scrollTop;
}
set scrollPosition (pos) {
this.element.scrollTop = pos;
}
/**
* @param {ToggleOverlayButton} buttonAutoScroll
**/
set autoScrollButton (buttonAutoScroll) {
this.buttonAutoScroll = buttonAutoScroll;
}
/**
* @return {ToggleOverlayButton} buttonAutoScroll
**/
get autoScrollButton () {
return this.buttonAutoScroll;
}
onScroll (e) {
if (this.interval === null ) {
// reached bottom
if (this.element.scrollHeight - this.element.scrollTop === this.element.clientHeight) {
this.startAutoScroll();
this.lastTimestamp = e.timeStamp;
}
} else {
// remove auto-scroll
if (!this.events.isKeyPressed("Alt")
&& (this.events.isAnyPressed(["PageUp", "PageDown"]) || this.events.mouseWheelEvent)
&& e.timeStamp - this.lastTimestamp > 1500) {
this.stopAutoScroll();
}
}
}
static getChatContainerFromDOM () {
return query(selector("chatContainer"));
}
}
class ChatInput extends ElementController {
constructor (element, opacity) {
super(element);
this.opacity = opacity;
}
/**
* @param {HTMLElement} el
**/
set element(el) {
super.element = el;
// since the element of the controller can change set all
// the listeners/attributes in the setter of the element
this.id = "chat-input-fs";
}
get element() {
return super.element;
}
hide () {
this.element.setAttribute('style', "display: none !important");
this.getRealInput().removeEventListener("click", this.onClick.bind(this));
}
show () {
this.element.setAttribute('style', "display: block !important");
this.getRealInput().addEventListener("click", this.onClick.bind(this));
}
/** this class holds the input container
* and with this function returns the
* textarea element
**/
getRealInput () {
return this.element.querySelector(selector("realChatInput"));
}
/**
* Moves the element to the initial parent
**/
moveToInitialParent () {
this.initialParent.insertBefore(
this.element,
this.initialParent.children[this.initialParent.children.length - 1]
);
}
onClick() {
const input = this.getRealInput();
input.focus(); // triggers chat_input:focus-within
input.setAttribute("data-focus-visible-added", "");
}
static getChatInputFromDOM () {
let node = query(selector("chatInputAreaWithoutOverlay"));
return node;
}
}
class UserCard extends ElementController {
constructor (element) {
super(element);
}
static getUserCardFromDOM () {
return query(selector("chatUserCard"));
}
}
class ToggleOverlayButton extends ElementController {
/**
* @param {ChatContainer} chatContainer
* @param {ChatInput} chatInput
* @param {Configuration} config
* @param {int} preset
**/
constructor (chatContainer, chatInput, config, preset) {
super(createElementFromHTML(getOverlayTemplate(preset)));
this.id = "toggle-chat-overlay-fs";
this.preset = preset;
this.chatContainer = chatContainer;
this.chatInput = chatInput;
this.config = config;
this.on("click", this.onClick);
}
/**
* @param {int} preset
**/
set preset(preset) {
this.element.classList.replace("preset-" + this._preset, "preset-" + preset);
this._preset = preset;
}
get preset () {
return this._preset;
}
/**
* Moves the element to the elemnt
* @param {HTMLElement} to
**/
move (to) {
to.insertBefore(this.element, to.children[0]);
}
/**
* @param {Event} e
**/
onClick (e) {
var isVOD = this.config.get("is_vod");
var matching = e.target.closest(this.idSelector);
if (matching) {
var preset = this.preset + 1;
preset = preset === 3 ? 0 : (preset === 1 && isVOD ? 2 : preset);
switch (preset) {
case 0:
this.chatContainer.show();
if (!isVOD) {
this.chatInput.hide();
}
break;
case 1:
this.chatInput.show();
break;
case 2:
this.chatContainer.hide();
break;
}
this.preset = preset;
}
}
}
class AutoScrollButton extends ElementController {
/**
* @param {ChatContainer} chatContainerInstance
* @param {boolean} stop_on_hover
**/
constructor (chatContainerInstance, stop_on_hover) {
super(
createElementFromHTML(
getAutoScrollButtonTemplate(
"display: none",
stop_on_hover ? 1 : 0
)
)
);
this.chat = chatContainerInstance;
this.on("click", this.onClick);
}
onClick () {
this.chat.startAutoScroll();
this.hide();
}
}
class ModeratorMenu extends ElementController {
/**
* @param {Moderator} moderator
* @param {ChatContainer} chatContainer
**/
constructor (chatContainer) {
super(createElementFromHTML(getModeratorMenuTemplate()));
this.chat = chatContainer;
this.on("click", this.onClick);
this.hide();
}
/**
* @param {Moderator} moderator
**/
set moderator (moderator) {
this._moderator = moderator;
}
get moderator () {
return this._moderator;
}
onClick (e) {
var lastScrollTop = this.chat.scrollPosition;
this.moderator.handleMenuClickEvent(e.target);
// fix: twitch resetting the chat scroll to the top after ban...
this.chat.scrollPosition = lastScrollTop;
}
centerMenu (chat_line, author_label, scrollPosition) {
var top = chat_line.offsetTop - scrollPosition - this.element.clientHeight / 2;
this.element.style.top = top + "px";
this.element.style.left = author_label.offsetLeft / 2 + "px";
}
}
/**
* Abstract
**/
class ReactInstance {
constructor () { }
// gets the property name that holds the react component instance
static getReactInstance(element) {
const key = Object.keys(element).find(k => k.toLowerCase().indexOf("internalinstance") >= 0);
return key && element[key];
}
// searches a react component that meets the condition
static getReactProp(element, condition, max_deep = 30) {
var node = this.getReactInstance(element);
while (node && !condition(node) && max_deep > 0) {
node = node.return; // node.return is the parent instance
max_deep--;
}
return max_deep >= 0 ? node : null;
}
}
class Twitch {
/**
* @param {ChatInput} chatInputInstance This element has all the props inside that we need, e.g. if the user is mod
**/
constructor (chatInputInstance) {
this.chat = chatInputInstance.element;
this.lastUrl = window.location.href;
this.setProps();
}
/**
* To update the props change the chat
* @param {HTMLElement} chat
**/
set chat (chat) {
this._chat = chat;
this.setProps();
}
get chat () {
return this._chat;
}
isCurrentUserModerator () {
return this.props?.isCurrentUserModerator;
}
sendMessage (msg) {
this.props.onSendMessage(msg);
}
setProps () {
if (!this.props || this.lastUrl != window.location.href) {
this.lastUrl = window.location.href;
this.props = ReactInstance.getReactProp(
this.chat,
(node) => node.stateNode && node.stateNode.props && node.stateNode.props.onSendMessage
);
if (this.props) {
this.props = this.props.stateNode.props;
}
}
}
}
/** This handles all the commands to ban/timeout and un-ban users
*
* You can directly ban a user by calling banUser, same with timeout.
* But to handle the difference in time between the click on a user and
* the time that the user choices an option, first you need to set the user
* or the messageid.
* Then handleMenuClickEvent uses this user to apply the command.
*
* https://help.twitch.tv/s/article/chat-commands
**/
class Moderator {
/**
* @param {Twitch} twitch
* @param {ModeratorMenu} moderatorMenu
**/
constructor (twitch, moderatorMenu) {
this.twitch = twitch;
this.modMenu = moderatorMenu;
}
ban (user) {
this.sendCommand("/ban " + user);
}
unban (user) {
this.sendCommand("/unban " + user);
}
timeout (user, time) {
this.sendCommand("/timeout " + user + " " + time);
}
deleteMessage (messageid) {
this.sendCommand("/delete " + messageid);
}
handleMenuClickEvent (element) {
if ("ban" in element.dataset) {
this.ban(this.user);
} else if ("unban" in element.dataset) {
this.unban(this.user);
} else if ("timeout" in element.dataset) {
this.timeout(this.user, parseInt(element.dataset.timeout));
} else if ("deletemessage" in element.dataset) {
this.deleteMessage(this.messageid);
}
this.modMenu.hide();
}
/**
* @param {string} user
**/
set user (user) {
this._user = user;
}
get user () {
return this._user;
}
/**
* @param {string} messageid
**/
set messageid (messageid) {
this._messageid = messageid;
}
get messageid () {
return this._messageid;
}
sendCommand (command) {
this.twitch.sendMessage(command);
}
}
class Configuration {
constructor (configuration) {
this.config = configuration;
}
set(key, value) {
this.config[key] = value;
}
get(key) {
return this.config[key];
}
}
/**
* This class keeps a register of the keys pressed (only the ones that you pass on observe)
*
* Get the key name (not key code) from this list https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
* or by pressing it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
**/
class UserEvents {
/**
* @param {Array<String>} observe
**/
constructor (observe = []) {
this.keysPressed = [];
this.observe = observe;
document.body.addEventListener('keydown', (event) => {
if (this.observe.includes(event.key)) {
this.keyPressed(event.key);
}
});
document.body.addEventListener('keyup', (event) => {
if (this.observe.includes(event.key)) {
this.keyRealeased(event.key);
}
});
this._mouseWheelEvent = false;
}
isKeyPressed (key) {
return this.keysPressed.indexOf(key) >= 0;
}
/**
* Checks if any of the keys is pressed
* @param {Array<String>} keys
**/
isAnyPressed (keys) {
return this.keysPressed.findIndex(k => keys.indexOf(k) >= 0) >= 0;
}
/**
* @param {string} key
**/
keyPressed (key) {
if (!this.isKeyPressed(key)) {
this.keysPressed.push(key);
}
}
keyRealeased (key) {
this.keysPressed = this.keysPressed.filter(k => k != key);
}
/**
* Removes all keys pressed and not released until now
**/
releaseAll () {
this.keysPressed = [];
}
/**
* @param {string} key
**/
observeKey (key) {
this.observe.push(key);
}
/**
* @param {boolean} value
**/
set mouseWheelEvent(value) {
this._mouseWheelEvent = value;
}
/**
* @return {boolean} is mouse wheel event
**/
get mouseWheelEvent() {
return this._mouseWheelEvent;
}
}
class ChatFS {
constructor () {
this.enabled = null;
}
get preset() {
return this.toggleOverlayButton.preset;
}
onClick (e) {
const { target } = e;
if (!isVOD && this.twitch.isCurrentUserModerator()) {
if (target.classList.value.indexOf("author") >= 0) {
var usermessage = ReactInstance.getReactProp(target, node => node && node.stateNode && node.stateNode.props && node.stateNode.props.message);
if (usermessage) {
const { id: messageid, user } = usermessage.stateNode.props.message;
this.moderator.messageid = messageid;
this.moderator.user = user.userLogin;
if (!this.events.isKeyPressed("Control")) {
var chat_line = e.path.find(el => el.classList.contains("chat-line__message")); // chat_line should be the the div that contains the message content
this.modMenu.centerMenu(chat_line, target, this.chatContainer.scrollPosition);
this.modMenu.show();
} else {
this.moderator.timeoutUser(user.userLogin, 60);
}
}
e.stopPropagation();
}
}
}
onWheel (e) {
if (this.events.isKeyPressed("Alt")) {
e.preventDefault();
var bcop = this.config.get("overlay_opacity");
bcop += 5 * e.deltaY / Math.abs(e.deltaY);
bcop = bcop > 100 ? 100 : (bcop < 0 ? 0 : bcop);
this.config.set("overlay_opacity", bcop);
this.overlay.opacity = bcop;
this.chatContainer.opacity = bcop;
this.chatInput.opacity = bcop;
} else {
this.events.mouseWheelEvent = true;
// force to change the scroll position
this.chatContainer.scrollPosition = this.chatContainer.scrollPosition + e.deltaY;
}
}
onMouseEnter (e) {
if (!this.config.get("is_vod")) {
this.chatInput.show();
this.chatInput.opacity = this.config.get("overlay_opacity");
}
if (e.target.classList.contains("chat-line__message") && this.config.get("stop_on_hover")) {
this.chatContainer.stopAutoScroll();
}
}
onMouseLeave () {
if (!this.config.get("is_vod") && this.preset != 1) {
this.chatInput.hide();
}
if (this.config.get("stop_on_hover")) {
this.chatContainer.startAutoScroll();
}
}
hide () {
this.overlay.hide();
/// move chat
this.chatContainer.moveToInitialParent();
this.chatContainer.stopAutoScroll();
this.chatContainer.opacity = 0;
if (!this.config.get("is_vod")) {
this.chatInput.moveToInitialParent();
this.chatInput.show();
}
// hide scroll button
this.autoScrollButton.hide();
this.toggleOverlayButton.hide();
if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) {
this.userCard.moveToInitialParent();
}
this.enabled = false;
}
show () {
this.overlay.height = this.config.get("overlay_height");
this.overlay.width = this.config.get("overlay_width");
this.overlay.moveToPlayer();
this.overlay.show();
// update chat container element bc it changes when you change the channel
this.chatContainer.element = query(selector("chatContainer"));
this.chatContainer.move(this.overlay.element);
if (!this.config.get("is_vod")) {
this.chatInput.element = ChatInput.getChatInputFromDOM();
this.twitch.chat = this.chatInput.element;
if (this.preset !== 1) {
this.chatInput.hide();
}
// Set the background of the chat input to transparent
var textarea = this.chatInput.getRealInput();
if (textarea) {
textarea.style.background = "transparent";
}
this.chatInput.move(this.overlay.element);
this.chatInput.opacity = this.config.get("overlay_opacity");
console.log("opacity:", this.config.get("overlay_opacity"));
}
this.chatContainer.startAutoScroll();
this.toggleOverlayButton.move(query(selector("playerLayoutControls")));
this.toggleOverlayButton.show();
/// set video close to the bottom. Is not relevant to the overlay but it looks nice
// query(selector("playerContainer")).style.bottom = "0px"
if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) {
this.userCard.move(this.overlay.element);
}
if (this.preset === 2) {
this.overlay.hide();
}
this.enabled = true;
}
setEvents () {
this.overlay.on("click", this.onClick.bind(this));
this.overlay.on("wheel", this.onWheel.bind(this));
this.overlay.on("mouseenter", this.onMouseEnter.bind(this));
this.overlay.on("mouseleave", this.onMouseLeave.bind(this));
}
create () {
let styles = getModeratorMenuStyle() + getStyles() /*add all the others styles*/;
this.config = new Configuration({
// chat
stop_on_hover: false,
// overlay
overlay_width: window.innerWidth * 0.23,
overlay_height: window.innerHeight * 0.5,
overlay_opacity: 23,
initial_preset: 0,
is_vod: window.location.pathname.indexOf("video") >= 0
});
this.events = new UserEvents(["Alt", "PageUp", "PageDown", "Control"]);
this.overlay = new OverlayContainer(
this.config.get("overlay_opacity"),
this.config.get("overlay_width"),
this.config.get("overlay_height"),
this.onChangeOverlayHeight.bind(this),
this.onChangeOverlayWidth.bind(this)
);
this.chatContainer = new ChatContainer(
ChatContainer.getChatContainerFromDOM(),
this.config.get("overlay_opacity"),
this.events
);
this.chatInput = new ChatInput(
ChatInput.getChatInputFromDOM(),
this.config.get("overlay_opacity")
);
this.userCard = new UserCard(
UserCard.getUserCardFromDOM()
);
this.toggleOverlayButton = new ToggleOverlayButton(
this.overlay,
this.chatInput,
this.config,
this.config.get("initial_preset")
);
this.autoScrollButton = new AutoScrollButton(
this.chatContainer,
this.config.get("stop_on_hover")
);
this.modMenu = new ModeratorMenu(this.chatContainer);
this.twitch = new Twitch(this.chatInput);
this.moderator = new Moderator(this.twitch, this.modMenu);
this.modMenu.moderator = this.moderator;
this.chatContainer.buttonAutoScroll = this.autoScrollButton;
this.modMenu.move(this.overlay.element);
this.autoScrollButton.move(this.overlay.element);
this.setEvents();
// Clear UserEvents when the window lost focus
window.addEventListener('blur', () => {
this.events.releaseAll();
});
insertStyle(styles);
}
toggle () {
// if enabled is null then is the first time it's toggled.
if (this.enabled === null) {
this.create();
}
// is vod?
this.config.set("is_vod", window.location.pathname.indexOf("video") >= 0);
// restart key press events
this.events.releaseAll();
SolutionBTTVPinIssue();
// Handle toggle overlay when user enters fullscreen mode
if (!this.enabled) {
this.show();
} else {
this.hide();
}
}
onChangeOverlayHeight(height) {
this.config.set("overlay_height", height);
}
onChangeOverlayWidth(width) {
this.config.set("overlay_width", width);
}
}
function main() {
var isMouseEvent = null;
var overlay = new ChatFS();
// allow changes from console for testing
window.overlay = overlay;
window.selector = selector;
window.query = query;
//// TOGGLE OVERLAY ON FULL SCREEN:
// If the button is pressed, the "click" event is triggered first and the isMouseEvent is set to
// true so that when the fullscreen event is triggered it doesnt toggle the overlay again.
query(selector("fullScreenButton")).addEventListener("click", (e) => {
e.preventDefault();
isMouseEvent = true;
overlay.toggle();
});
document.onfullscreenchange = () => {
if (!isMouseEvent) {
overlay.toggle();
}
isMouseEvent = false;
};
}
})();