123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html>
<head>
<meta charset="UTF-8">
<title>Twitch Chat Viewer</title>
<style>
body {
background: #262623;
color: #ccc;
padding: 20px;
margin: 0;
height: 100vh;
box-sizing: border-box;
}
#everything {
height: 100%;
display: flex;
flex-flow: column;
background: #262623;
max-width: 80ch;
margin-left:auto;
margin-right:auto;
}
footer {
margin-top: 16px;
text-align: center;
font-style: italic;
color: #555;
}
h1 {
font-size: 1.75em;
color: #ddd;
}
h2 {
font-size: 1.5em;
color: #ddd;
}
iframe {
display: block;
}
a {
color: #abf;
}
a:visited {
color: #cad;
}
.header {
display: flex;
flex-flow: row;
align-items: center;
}
.header-section {
margin: 10px;
}
.chat-input-container {
width: 100%;
display: flex;
flex-direction: row;
height:100%;
}
textarea.chat-input {
flex: 1;
resize: none;
}
.chat-submit {
height: 100%;
}
form {
display: inline;
}
.logged-in {
font-weight: bold;
}
.chat {
font: "Roobert","Helvetica Neue",Helvetica,Arial,sans-serif;
/*font: "Korolev","Helvetica Neue","Helvetica",sans-serif;*/
width: auto;
flex: 1;
border: 2px solid black;
overflow: auto;
padding: 8px;
}
.username {
font-weight: bold;
}
.message {
color: #ccc;
}
.sound-area {
padding: 8px;
}
.sound-upload {
margin-bottom: 12px;
}
.sound-item {
display: inline-block;
}
input[type=checkbox][disabled] {
filter: invert(30%);
}
table {
display: inline-block;
}
td {
padding: 6px;
}
td:first-of-type {
text-align: right;
font-weight: bold;
color: #ddd;
}
.btn {
box-shadow:inset 0px 1px 0px 0px #caefab;
background:linear-gradient(to bottom, #77d42a 5%, #5cb811 100%);
background-color:#77d42a;
border-radius:6px;
border:1px solid #268a16;
display:inline-block;
cursor:pointer;
color:#306108;
font-family:Arial;
font-size:15px;
font-weight:bold;
padding:6px 24px;
text-decoration:none;
text-shadow:0px 1px 0px #aade7c;
}
.btn:hover {
background:linear-gradient(to bottom, #5cb811 5%, #77d42a 100%);
background-color:#5cb811;
}
.btn:active {
position:relative;
top:1px;
}
.btn-red {
box-shadow:inset 0px 1px 0px 0px #f7c5c0;
background:linear-gradient(to bottom, #fc8d83 5%, #e4685d 100%);
background-color:#fc8d83;
border-radius:6px;
border:1px solid #d83526;
display:inline-block;
cursor:pointer;
color:#ffffff;
font-family:Arial;
font-size:15px;
font-weight:bold;
padding:6px 24px;
text-decoration:none;
text-shadow:0px 1px 0px #b23e35;
}
.btn-red:hover {
background:linear-gradient(to bottom, #e4685d 5%, #fc8d83 100%);
background-color:#e4685d;
}
.btn-red:active {
position:relative;
top:1px;
}
</style>
<script src="https://alca.sfo2.cdn.digitaloceanspaces.com/tmijs/1.4.2/tmi.min.js"></script>
<script>
// Embed a youtube video clip
function summonClip(parent, username, clip) {
let url = "https://www.youtube.com/embed/"+clip.videoID+"?autoplay=1&start="+clip.start+"&end="+clip.end;
let mutedLink = function(reason) {
let link = document.createElement('a');
link.setAttribute('href', url);
link.textContent = "("+reason+": " + clip.cmd+')';
parent.appendChild(link);
}
if (mute) {
mutedLink("Global mute");
} else if (clip.disabled) {
mutedLink("Sound disabled");
} else if (Date.now() < clip.cooldown + (lastPlayTimes[clip.videoID]||0)) {
mutedLink("On cooldown");
} else if (Date.now() < clip.userCooldown + (lastPlayTimes[clip.videoID+';'+username]||0)) {
mutedLink("On user cooldown");
} else {
let iframe = document.createElement('iframe');
lastPlayTimes[clip.videoID] = Date.now();
lastPlayTimes[clip.videoID+';'+username] = Date.now();
iframe.setAttribute('src', url);
iframe.setAttribute('allow', 'autoplay');
parent.appendChild(iframe);
}
}
function mixRGB(c1, c2, amount) {
const reSegment = /[\da-z]{2}/gi;
// Split into parts
var b1 = c1.replace("#","").match(reSegment);
var b2 = c2.replace("#","").match(reSegment);
var t, c = [];
for (var i=b1.length; i;) {
t = Math.round((1-amount)*parseInt(b1[--i],16)
+ amount*parseInt(b2[i],16)).toString(16);
// Add leading zero if only one character
c[i] = t.length == 2? '' + t : '0' + t;
}
return '#'+c.join('');
}
function toggleMute(newMuted) {
mute = newMuted;
let btn = document.getElementById('mute-button');
btn.innerHTML = mute ? 'Enable Audio' : 'Disable Audio';
btn.className = mute ? 'btn' : 'btn-red';
for (let check of document.getElementsByClassName('sound-enabled')) {
check.disabled = mute;
}
}
// Parse duration strings into milliseconds
function parseDuration(str) {
switch (str[str.length-1]) {
case "d": return 1000*60*60*24*parseFloat(str);
case "h": return 1000*60*60*parseFloat(str);
case "m": return 1000*60*parseFloat(str);
default: return 1000*parseFloat(str);
}
}
// Replace emote strings with HTML images:
function emotify(msg, emotes) {
let sortedEmotes = [];
for (let emoteID in emotes) {
for (let instance of emotes[emoteID]) {
let [start, end] = instance.match(/\d+/g);
start = 1*start; end = 1*end;
let emoteName = msg.substring(start,end+1);
sortedEmotes.push({
start:start, end:end,
html:"<img title='"+emoteName+"' src='https://static-cdn.jtvnw.net/emoticons/v1/"+emoteID+"/1.0'></img>",
});
}
}
// iterate backwards through emotes:
sortedEmotes.sort((e1,e2)=>e1.start < e2.start);
var ret = msg;
for (let e of sortedEmotes) {
ret = (ret.substring(0, e.start) + e.html + ret.substring(e.end+1));
}
return ret;
}
</script>
</head>
<body>
<div id='everything'>
<h1>Twitch SFXer</h1>
<p>This page loads a Twitch channel's comments, and plays sound effects from youtube videos. Check out an example sound effects file <a href='example_sfx.txt'>here</a>.</p>
<form class='header' onsubmit='toggleTwitch(); return false'>
<table>
<tr><td>Channel:</td>
<td><input type='text' id='channel'></td></tr>
<tr><td>Username (optional):</td>
<td><input type='text' id='username'></td></tr>
<tr><td>OAuth <a title='Click here to get a Twitch OAuth login token' href='https://twitchapps.com/tmi/'>[?]</a> (optional):</td>
<td><input type='password' id='oauth'></td></tr>
</table>
<a class='btn' href="javascript:{}" style='line-height:120px' id='connect-btn'
onclick="this.closest('form').onsubmit(); return false;">Connect</a><td></td>
</form>
<h2>Sounds</h2>
<div id='sounds' class='sound-area'></div>
<div id='sound-upload' class='sound-upload'>
<input type='file' id='sound-file' onclick='this.value = null' onchange='loadSounds(this.files[0])' style='display:none;'>
<label for='sound-file' class='btn'>Load sounds from file...</label>
<a class='btn-red' id='mute-button' href='javascript:{}' onClick="toggleMute(!mute)">Disable Audio</a>
<br>
<input type='text' id='sound-test' style='width:600px' value='KWreHiN_E-Q@9-12'>
<a class='btn' id='sound-test-button' href='javascript:{}' onClick="testSound()">Test Sound</a>
</div>
<div id='chat-container' class='chat'></div>
<div>
<form class='chat-input-container' onsubmit='sendChat(); return false'>
<textarea id='chat-input' class='chat-input'
placeholder='Type a message here...'></textarea>
<a style='line-height:30px' class='btn' href="javascript:{}" onclick="this.closest('form').submit(); return false;">Send</a>
<script>
function submitOnEnter(event){
if(event.which === 13){
event.target.form.dispatchEvent(new Event("submit", {cancelable: true}));
event.preventDefault();
}
}
document.getElementById('chat-input').addEventListener('keypress', submitOnEnter);
</script>
</form>
</div>
<footer>
Made by Bruce Hill 2020
</footer>
</div>
</body>
<script>
mute = false;
twitchClient = null;
var sounds = {};
var lastPlayTimes = {};
const container = document.getElementById("chat-container");
// Messages get deleted from the browser after this time interval
const messageLifetime = 5*60*1000; // 5 min
var channel = localStorage.twitchSFX_channel;
if (channel) document.getElementById('channel').value = channel;
var username = localStorage.twitchSFX_username;
if (username) document.getElementById('username').value = username;
var oauth = localStorage.twitchSFX_oauth;
if (oauth) document.getElementById('oauth').value = oauth;
if (localStorage.twitchSFX_sounds) loadSounds(localStorage.twitchSFX_sounds);
function sendChat() {
if (!twitchClient || !channel) return;
let input = document.getElementById("chat-input");
twitchClient.say(channel, input.value);
input.value = "";
}
function parseSound(attrs) {
// Example:
// KWreHiN_E-Q@9-12 cooldown=1m userCooldown=2m disabled
let cmdObj = {
cooldown: parseDuration('1m'),
userCooldown: parseDuration('10m'),
start: 0,
end: 9999,
};
attrs.split(' ').filter(x=>x).map(x=>x.trim()).forEach((attr)=> {
if (attr.startsWith('cooldown=')) {
cmdObj.cooldown = parseDuration(attr.split('=',2)[1]);
} else if (attr.startsWith('userCooldown=')) {
cmdObj.userCooldown = parseDuration(attr.split('=',2)[1]);
} else if (attr == 'disabled' || attr == 'disabled=true' || attr == 'enabled=false') {
cmdObj.disabled = true;
} else {
let [video,range] = attr.split('@',2);
if (video.startsWith('https://') || video.startsWith('http://')) {
cmdObj.videoID = video.match(/v=([^&]+)/)[1];
} else {
cmdObj.videoID = video;
}
if (range) {
let [start,end] = range.split('-');
cmdObj.start = start;
cmdObj.end = end;
}
}
});
if (!cmdObj.videoID) {
return;
}
return cmdObj;
}
function testSound() {
let sound = parseSound(document.getElementById('sound-test').value);
if (!sound) {
alert("Sound failed to parse.");
}
let elem = document.createElement('div');
summonClip(elem, '<test>', sound);
container.appendChild(elem);
let doScroll = container.scrollHeight - container.scrollTop - 50 <= container.clientHeight;
container.appendChild(elem);
if (doScroll) elem.scrollIntoView();
setTimeout(function() {
container.removeChild(elem);
}, messageLifetime);
}
function loadSounds(soundsFile) {
if (typeof soundsFile != "string") {
var reader = new FileReader();
reader.onload = function(e) {
loadSounds(reader.result);
}
reader.readAsText(soundsFile);
return;
}
localStorage.twitchSFX_sounds = soundsFile;
sounds = {};
let soundsElem = document.getElementById('sounds');
while (soundsElem.firstChild) {
soundsElem.removeChild(soundsElem.firstChild);
}
// Parse the config file:
soundsFile.split('\n').forEach((line, lineNum)=> {
if (!line || line.startsWith("#"))
return;
// Example: foo/baz -> ...
let [cmds, attrs] = line.split('->',2).map(x=>x.trim());
if (!cmds || !attrs) {
return;
}
let cmdObj = parseSound(attrs);
if (!cmdObj)
return;
cmds.split('/').map(x=>x.trim()).forEach((cmd)=> {
sounds[cmd] = {cmd: cmd, ...cmdObj};
});
let soundItem = document.createElement('div');
soundItem.className = 'sound-item';
soundItem.innerHTML = "<input type='checkbox' class='sound-enabled' onclick='sounds[\""+cmds+"\"].disabled = !this.checked' "+(this.disabled?'':'checked')+">"
+"<a href='https://www.youtube.com/embed/"+cmdObj.videoID+"?autoplay=1&start="+cmdObj.start+"&end="+cmdObj.end+"' target='_blank'>"+cmds+"</a>";
soundsElem.appendChild(soundItem);
});
}
function toggleTwitch() {
if (twitchClient) {
disconnectTwitch();
} else {
connectTwitch();
}
}
function disconnectTwitch() {
if (!twitchClient) return;
twitchClient.disconnect().then(function() {
twitchClient = null;
})
}
function connectTwitch() {
if (twitchClient) return;
localStorage.twitchSFX_channel = channel = document.getElementById('channel').value;
localStorage.twitchSFX_username = username = document.getElementById('username').value;
localStorage.twitchSFX_oauth = oauth = document.getElementById('oauth').value;
twitchClient = new tmi.client({
identity: (username && oauth) ? {
username: username,
password: oauth,
} : null,
connection: {secure: true},
channels: [channel],
});
twitchClient.on('connected', function(addr, port) {
console.log("Connected to "+addr+":"+port);
let joined = document.createElement("div");
joined.innerHTML = "<em>Joined #"+channel+".</em>";
container.appendChild(joined);
let btn = document.getElementById('connect-btn');
btn.textContent = 'Disconnect';
btn.className = 'btn-red';
});
twitchClient.on('disconnected', function() {
let leftMsg = document.createElement("div");
leftMsg.innerHTML = "<em>Left #"+channel+".</em>";
container.appendChild(leftMsg);
container.appendChild(document.createElement('hr'));
let btn = document.getElementById('connect-btn');
btn.textContent = 'Connect';
btn.className = 'btn';
});
var idCounter = 0;
twitchClient.on('message', function(target, context, msg, self) {
var msgElem = document.createElement("div");
msgElem.id = "chat-"+idCounter++;
var name = document.createElement("span");
name.style.color = mixRGB((context.color || "#FFFFFF"), "#FFFFFF", 0.3);
name.className = "username";
name.textContent = context["display-name"]+": ";
msgElem.appendChild(name);
var msgText = document.createElement("span");
var msgImaged = msg;
if (context.emotes) {
msgImaged = emotify(msg, context.emotes);
}
msgText.innerHTML = msgImaged;
msgText.className = "message";
msgElem.appendChild(msgText);
for (var word of msg.split(' ')) {
if (sounds[word]) {
summonClip(msgElem, context["display-name"], sounds[word]);
break;
}
}
let doScroll = container.scrollHeight - container.scrollTop - 50 <= container.clientHeight;
container.appendChild(msgElem);
if (doScroll) msgElem.scrollIntoView();
setTimeout(function() {
container.removeChild(msgElem);
}, messageLifetime);
});
twitchClient.connect();
}
</script>
</html>
|