Download
531 lines 15.5 KiBHTMLModified 2021-05-26 raw
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>