Skip to content

Instantly share code, notes, and snippets.

@craftxbox
Last active January 19, 2024 02:44
Show Gist options
  • Save craftxbox/cf5b7e67c1bf3126a7d90c678ded8ebc to your computer and use it in GitHub Desktop.
Save craftxbox/cf5b7e67c1bf3126a7d90c678ded8ebc to your computer and use it in GitHub Desktop.
Multistream chat overlay/aggregate for Owncast and Sheepchat
<!DOCTYPE html>
<html lang="en">
<!--
* Multi-stream chat aggregator for Owncast and Sheepchat.
* Copyright (C) 2024 craftxbox
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* Usage:
* chatview.html/
* ?overlay = [true|false] (default: false) - Enable background transparency for use in OBS
* &showLinks = [true|false] (default: false) - Show links in chat (else they will become [Link Removed])
* &corsProxy = [https://corsproxy.io/?] - CORS proxy to use for fetching images.
* &owncastUri = <owncast.example.com> - Base URI of Owncast instance
* &owncastToken = <token> - Owncast access token. See https://owncast.online/thirdparty/apis/. Only "User Chat Messages" scope is required.
* &sheepUri = [127.0.0.1:49135] - Base URI of Sheepchat instance. You do not need to add this by default.
* &sheepRoom = <g76561198091287090-aogokni5> - Sheepchat "room" to join. This is the part after the "#" in the chat widget URL.
* Found on Settings -> Stream Output -> Widgets -> Chat Widget
* &ircUri = [irc.example.com:6698] - Base URI of IRC server. Optional, will not connect by default.
* The Server used MUST support Websockets in TEXT MODE. (very few do)
* &ircChannel = <#channel> - IRC channel to join. Optional, will not connect by default.
*
* For any help with setup, reach me on discord: https://discord.gg/Mu33sKU2KC
* Or on Mastodon: https://transfur.social/@craftxbox
-->
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.5.0/socket.io.min.js"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/validator@latest/validator.min.js"></script>
<script src="https://unpkg.com/he@1.2.0/he.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
body {
background-color: #222;
color: #fff;
}
body.overlay {
background-color: rgba(0, 0, 0, 0);
margin: 0px auto;
margin-left: 5px
}
.display-color-0 {
color: #ff717b;
}
.display-color-1 {
color: #f4e413;
}
.display-color-2 {
color: #b99c45;
}
.display-color-3 {
color: #58f40b;
}
.display-color-4 {
color: #0bf4f4;
}
.display-color-5 {
color: #16a8f7;
}
.display-color-6 {
color: #9a92ff;
}
.display-color-7 {
color: #ff53ff;
}
div#chat {
width: calc(100vw - 10px);
height: calc(100vh - 10px);
background-color: #0000;
color: #fff;
padding: 10px;
font-family: "Anonymous Pro";
font-size: 20px;
overflow-x: wrap;
overflow-y: scroll;
overflow-wrap: break-word;
}
a {
color: #fff;
}
::-webkit-scrollbar {
display: none;
}
p {
background-color: #353535;
border-radius: 6px;
margin: 4px;
padding: 5px;
width: max-content;
max-width: 95vw;
white-space: break-spaces;
}
p.overlay {
background-color: #2227;
}
.emoji {
display: inline-block;
position: relative;
width: 25px;
height: 25px;
top: 5px;
}
.preview {
max-width: 90%;
max-height: 150px;
display: block;
border-radius: 10px;
}
.fa-twitch {
color: #6441a5
}
.fa-youtube {
color: red
}
.fa-tower-broadcast {
color: #64DD17
}
.fa-computer {
color: orange;
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.removing {
animation: fadeOut 0.25s ease-in-out;
}
</style>
</head>
<body>
<div id="chat">
</div>
</body>
<script>
let params = (new URL(document.location)).searchParams;
let owncastUri = params.get("owncastUri") || "crxb.cc/stream";
let corsProxy = params.get("corsProxy") || "https://corsproxy.io/?";
let ircUri = params.get("ircUri") || null;
let secure = window.location.protocol == "https:";
let overlay = false;
if (params.get("overlay") == "true") {
document.body.classList.add("overlay")
overlay = true;
}
// (c) folo 2018 CC-BY-SA 4.0 https://stackoverflow.com/a/51121566/3934270
const isElementXPercentInViewport = function (el, percentVisible) {
let
rect = el.getBoundingClientRect(),
windowHeight = (window.innerHeight || document.documentElement.clientHeight);
return !(
Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-rect.height) * 100)) < percentVisible ||
Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
)
};
//end copynotice
// (c) jpfx1342 2022 CC-BY-SA 4.0 https://stackoverflow.com/a/70990824/3934270
// Easing function takes an number in range [0...1]
// and returns an eased number in that same range.
// See https://easings.net/ for more.
function easeInOutSine(x) { return -(Math.cos(Math.PI * x) - 1) / 2; }
// Simply scrolls the element from the top to the bottom.
// `elem` is the element to scroll
// `time` is the time in milliseconds to take.
// `easing` is an optional easing function.
function scrollToBottom(elem, time, easing, img = 0) {
var startTime = null;
var startScroll = elem.scrollTop;
// You can change the following to scroll to a different position.
var targetScroll = elem.scrollHeight - elem.clientHeight + (200 * img);
var scrollDist = targetScroll - startScroll;
easing = easing || (x => x);
function scrollFunc(t) {
if (startTime === null) startTime = t;
var frac = (t - startTime) / time;
if (frac > 1) frac = 1;
elem.scrollTop = startScroll + Math.ceil(scrollDist * easing(frac));
if (frac < 0.99999)
requestAnimationFrame(scrollFunc);
}
requestAnimationFrame(scrollFunc);
}
//end copynotice
function append(html,id) {
let el = document.createElement("p")
if (overlay) el.classList.add("overlay")
if (id) el.id = id
if (params.get("showLinks") == "true") el.innerHTML = html
else el.innerHTML = html.replaceAll(/<a .*<\/a>/ig, `<span style="font-size:14px;color:#999">[Link Removed]</span>`)
let hasImg = false
for (let i of html.match(/https?:\/\/\S+/gi) || []) {
try {
i = he.decode(i)
i = i.replace(/"$/, "")
if (validator.isURL(i, { protocols: ['http', 'https'], require_protocol: true })) {
if(i.includes(owncastUri)) continue;
const request = new XMLHttpRequest();
request.open("GET", (corsProxy) + he.encode(i), false);
request.send(null);
if (request.status === 200) {
let contentType = request.getResponseHeader("Content-Type");
if (contentType.startsWith("image")) {
hasImg = true
let img = document.createElement("img")
img.onload = function () {
chat.scrollTo(0, chat.scrollHeight)
}
img.src = (corsProxy) + he.encode(i)
img.classList.add("preview")
el.appendChild(img)
break;
}
}
}
} catch (e) {
console.log(e) //but discard so it doesnt break processing
}
}
chat.appendChild(el)
scrollToBottom(chat, 250, easeInOutSine, hasImg)
chat.scrollTo(0, chat.scrollHeight)
for (let el of document.querySelectorAll("#chat p")) {
if (!isElementXPercentInViewport(el, 50)) {
el.classList.add("removing")
setTimeout(() => {
el.remove()
chat.scrollTo(0, chat.scrollHeight)
}, 250)
}
}
}
let exponentialBackoff = 0;
let ircExponentialBackoff = 0;
function connectOwncast() {
let ws = new WebSocket((secure ? "wss://" : "ws://") + (owncastUri) + "/ws?accessToken=" + params.get("owncastToken"))
ws.onopen = function (_) {
exponentialBackoff = 0;
ws.onmessage = function (e) {
data = JSON.parse(e.data)
switch(data.type){
case "CHAT":
append(`<i class="fa-solid fa-tower-broadcast"></i> <span class="display-color-${data.user.displayColor}">&lt${data.user.displayName}&gt;</span> ${data.body.replace(/<p>(.*)<\/p>/, "$1").replaceAll("src=\"/", "src=\"https://crxb.cc/stream/")}\n`, data.id)
break;
case "NAME_CHANGE":
append(`<i class="fa-solid fa-tower-broadcast"></i> <span class="display-color-${data.user.displayColor}">${data.oldName}</span> is now known as <span class="display-color-${data.user.displayColor}">${data.user.displayName}</span>\n`,data.id)
case "VISIBILITY-UPDATE":
if(!data.visible){
for(let id of data.ids){
document.getElementById(id).remove()
}
chat.scrollTo(0, chat.scrollHeight)
}
break;
}
}
};
ws.onclose, ws.onerror = function (_) {
setTimeout(function () {
if (exponentialBackoff == 0) exponentialBackoff++;
else exponentialBackoff *= 2;
if (exponentialBackoff > 60) exponentialBackoff = 60;
connectOwncast();
}, exponentialBackoff * 1000);
};
}
function connectIrc() {
if (ircUri == null) return;
let ws = new WebSocket((secure ? "wss://" : "ws://") + (ircUri), ['text.ircv3.net'] )
ws.onopen = function (_) {
ircExponentialBackoff = 0;
let ready = false;
ws.send("NICK chatview-" + Math.random().toString(36).substring(2) + "\r\n")
ws.onmessage = function (e) {
data = e.data
let uid = e.data.split(" ")[0]
if(uid == "PING"){
ws.send("PONG " + data.split(" ")[1] + "\r\n")
if(!ready) {
ws.send("USER chatview 0 * :chatview\r\n")
ws.send("JOIN " + params.get("ircChannel") + "\r\n")
}
ready = true;
}
let nickColor;
if(uid.includes("!")){
nickColor = uid.split("!")[1].split("").reduce((a,b)=>a+b.charCodeAt(0),0) % 8
}
let command = data.split(" ")[1]
switch(command){
case "PRIVMSG":
let nick = uid.split("!")[0].substring(1)
console.log(data,data.split(":"))
let message = data.split(":")[2]
let channel = data.split(" ")[2]
if(channel != params.get("ircChannel")) return;
append(`<i class="fa-solid fa-computer"></i> <span class="display-color-${nickColor}">&lt${nick}&gt;</span> ${message}\n`)
break;
case "NICK":
let oldNick = uid.split("!")[0].substring(1)
let newNick = data.split(":")[2]
append(`<i class="fa-solid fa-computer"></i> <span class="display-color-${nickColor}">${oldNick}</span> is now known as <span class="display-color-${nickColor}">${newNick}</span>\n`)
break;
}
}
};
ws.onclose, ws.onerror = function (_) {
setTimeout(function () {
if (ircExponentialBackoff == 0) ircExponentialBackoff++;
else ircExponentialBackoff *= 4;
if (ircExponentialBackoff > 60) ircExponentialBackoff = 60;
connectIrc();
}, ircExponentialBackoff * 1000);
};
}
const socket = io(params.get("sheepUri") || "ws://127.0.0.1:49135", {
forceNew: true,
reconnect: true,
secure: true,
path: "/socket.io/",
transports: ['polling', 'websocket'],
upgrade: true,
query: {
session_id: Date.now(),
device_type: "listener",
os: "none",
pc_name: "none",
room: params.get("sheepRoom")
}
});
connectOwncast();
connectIrc();
socket.connect();
socket.on('connect', () => {
socket.emit("message", { event: "join", data: {} })
});
socket.on('message', (data) => {
let event = data.event
data = data.data;
switch (event) {
case "joined":
if (data.sender) {
//socket.emit("message", { event: "fetch", data: {}, socket_select: data.sender, method: "history" })
}
break;
case "broadcast":
if (data.broadcast_event != "message") return;
append(`<i class="fa-brands fa-${data.type}"></i> <span style="color:${data.color}">&lt;${data.nick}&gt;</span> ${data.text}\n`)
break;
}
console.log(data)
});
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment