Skip to content

Instantly share code, notes, and snippets.

@korc
Last active April 17, 2020 20:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save korc/c8ab5265e8b6199a4e95064a07274b82 to your computer and use it in GitHub Desktop.
Save korc/c8ab5265e8b6199a4e95064a07274b82 to your computer and use it in GitHub Desktop.
Video chat/screenshare web app over IRC

Video chat/screenshare web app over IRC

  • alpha release, expect bugs, like
    • can't join to on-going session
    • if video receive fails (for example due to frame drop), need to restart video at source
    • no audio
    • others

suggested setup

  • IRC server
  • Websocket-forwarding web server
    • suggested setup: host websocket and current files at same location
    • ex: websrv -map /=file:/data/web/html -map /ws/irc=websocket:{type=text}127.0.0.1:6667
"use strict";
let app = new Vue({
el: '#app',
data: () => ({
videoStream: null,
audioStream: null,
videoRecorder: null,
recordOptions: {
audioBitsPersecond: 128000,
videoBitsPersecond: 250000,
mimeType: 'video/webm;codecs=vp9'
},
recordInterval: 100,
chatTarget: "",
showJoin: false,
videoTarget: "",
hideSocket: false,
audioRecorder: null,
chatMessage: "",
chatUser: localStorage.getItem("chat-user"),
wschat: null,
chatSocket: localStorage.getItem("chat-socket") || "/ws/irc",
chatChannel: window.location.hash || localStorage.getItem("chat-channel") || "#general",
chatMessages: [],
type: "getUserMedia",
incomingStreams: [],
incomingVideoSource: null,
connectErrors: null,
incomingVideoSourceBuffer: null,
incomingVideoChunks: [],
incomingVideoURL: null,
errors: null
}),
watch: {
videoStream(video, oldVideo) {
if (oldVideo) this.stopTracks(oldVideo);
if (video) {
this.startTracks(video);
this.videoRecorder = new MediaRecorder(video, this.recordOptions);
this.videoRecorder.ondataavailable = this.onVideoRecorded;
this.videoRecorder.start(parseInt(this.recordInterval));
} else {
this.wschat.send(`PRIVMSG ${this.videoTarget} :\u0001STOPVIDEO\u0001`);
}
this.$refs['video-out'].srcObject = video;
},
audioStream(value, oldValue) {
if (oldValue) this.stopTracks(oldValue);
if (value) this.startTracks(value);
},
chatChannel(value) { localStorage.setItem("chat-channel", value); },
chatUser(value) { localStorage.setItem("chat-user", value); },
chatSocket(value) { localStorage.setItem("chat-socket", value); },
"chatMessages.length": function (value) {
this.$nextTick(() => {
if (!this.$refs.chatlog) return;
this.$refs.chatlog.lastChild.scrollIntoView();
// console.log("chatlog", value, this.$refs.chatlog);
});
},
"wschat.joinedChannels": function (value) {
if (value.length) {
if (!this.chatTarget) this.chatTarget = value[0].name;
if (!this.videoTarget) this.videoTarget = value[0].name;
}
}
},
methods: {
stopTracks(stream) {
stream.getTracks().forEach(track => track.stop());
},
startTracks(stream) {
stream.getTracks().forEach(track => track.addEventListener('ended', () => this.stopVideo()));
},
logError(err) {
let e = { error: err, name: err.name, message: err.message };
if (this.errors) this.errors.push(e);
else this.errors = [e];
},
startVideo() {
navigator.mediaDevices[this.type]({ video: true }).then(stream => {
this.videoStream = stream;
}).catch(this.logError);
},
stopVideo() {
this.videoStream = null;
},
startAudio() {
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
this.audioStream = stream;
}).catch(this.logError);
},
stopAudio() {
this.audioStream = null;
},
connectChat() {
this.wschat = new WSChat(this.chatSocket, {
onconnect: () => {
this.$forceUpdate();
this.wschat.login(this.chatUser);
this.wschat.onCommand("PRIVMSG", this.onChatMessage);
},
onclose: () => {
this.stopVideo();
this.stopAudio();
this.$forceUpdate();
},
onmessage: (msg) => { if (msg.command !== "PRIVMSG") console.log("Received message: ", msg) },
onerror: (err) => {
if (!this.connectErrors) this.connectErrors = [];
this.connectErrors.push(err);
}
});
},
onVideoRecorded(ev) {
let reader = new FileReader();
let chunkSize = 400;
reader.onload = () => {
let result = reader.result;
let n = Math.ceil(result.length / chunkSize);
for (let i = 0; i < n; i++) {
let chunk = result.slice(i * chunkSize, (i + 1) * chunkSize);
this.wschat.send(`PRIVMSG ${this.videoTarget} :\u0001VIDEO ${ev.timecode} ${i + 1} ${n} ${chunk}\u0001`);
}
}
reader.readAsDataURL(ev.data);
},
sendChatMessage() {
let msg = this.chatMessage;
if (!msg) return;
this.chatMessages.push({ source: "(me)", parameters: `${this.chatTarget} :${msg}` });
this.chatMessage = "";
this.wschat.send(`PRIVMSG ${this.chatTarget} :${msg}`);
},
onChatMessage(msg) {
if (msg.parameters.slice(msg.msgIdx, msg.msgIdx + 8) === ":\u0001VIDEO ") {
let endMark = msg.parameters.indexOf("\u0001", msg.msgIdx + 2);
if (endMark > 0)
this.onVideoChunk(msg.sourceNick || msg.source, msg.target, msg.parameters.slice(msg.msgIdx + 8, endMark));
else
console.warn("Chunk not fully received: ", msg);
} else if (msg.parameters.slice(msg.msgIdx, msg.msgIdx + 12) === ":\u0001STOPVIDEO\u0001") {
let idx = this.incomingStreams.findIndex(e => e.source === msg.sourceNick);
if (idx >= 0) this.incomingStreams.splice(idx, 1);
} else
this.chatMessages.push(msg);
},
onVideoChunk(source, target, chunk) {
let spc1 = chunk.indexOf(" ");
let spc2 = chunk.indexOf(" ", spc1 + 1);
let spc3 = chunk.indexOf(" ", spc2 + 1);
let c = {
id: chunk.slice(0, spc1),
i: parseInt(chunk.slice(spc1 + 1, spc2)) - 1,
n: parseInt(chunk.slice(spc2 + 1, spc3)),
}
let sourceStream = this.incomingStreams.filter(s => s.source === source)[0];
if (!sourceStream) {
sourceStream = {
source: source, target: target, mediaSource: new MediaSource(), incomingChunks: {},
videoType: chunk.slice(spc3 + 6, chunk.indexOf(";base64,", spc3)),
frames: [], canReceiveVideo: true,
};
sourceStream.mediaSource.onsourceopen = () => {
console.log("mediaSource onsourceopen, available frames:", sourceStream.frames.length);
sourceStream.ready = true;
sourceStream.sourceBuffer = sourceStream.mediaSource.addSourceBuffer(sourceStream.videoType);
sourceStream.sourceBuffer.onupdateend = () => {
if (sourceStream.frames.length > 0)
sourceStream.sourceBuffer.appendBuffer(sourceStream.frames.shift());
else
sourceStream.canReceiveVideo = true;
};
}
sourceStream.mediaSource.onsourceclose = () => {
console.log("mediaSource onsourceclose, video && video error: ", sourceStream.dom, sourceStream.dom && sourceStream.dom.error);
sourceStream.ready = false;
}
sourceStream.url = URL.createObjectURL(sourceStream.mediaSource);
this.incomingStreams.push(sourceStream);
}
if (!(c.id in sourceStream.incomingChunks))
sourceStream.incomingChunks[c.id] = new Array(c.n);
let chunkArray = sourceStream.incomingChunks[c.id];
chunkArray[c.i] = chunk.slice(spc3 + 1);
if (chunkArray.filter(e => typeof (e) === "string").length === chunkArray.length) {
let b64data = chunkArray.join("").slice(chunkArray[0].indexOf(",") + 1);
let binary;
try { binary = atob(b64data); }
catch (e) {
console.log("Could not decode: ", b64data);
return;
}
let buffer = new ArrayBuffer(binary.length);
let intBuffer = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) intBuffer[i] = binary.charCodeAt(i);
sourceStream.frames.push(buffer);
delete sourceStream.incomingChunks[c.id];
}
let v;
if (!sourceStream.dom && (v = (this.$refs[`video-in-${sourceStream.source}`])))
sourceStream.dom = v.shift();
while (sourceStream.frames.length > 0 && sourceStream.canReceiveVideo && sourceStream.sourceBuffer) {
if (sourceStream.dom && sourceStream.dom.error)
console.log("video.error:", sourceStream.dom.error);
else
sourceStream.sourceBuffer.appendBuffer(sourceStream.frames.shift());
sourceStream.canReceiveVideo = false;
}
},
onVideoReceived(ev) {
if (!this.incomingVideoSource) {
this.incomingVideoSource = new MediaSource();
this.incomingVideoURL = URL.createObjectURL(this.incomingVideoSource);
this.incomingVideoSource.onsourceopen = () => {
this.incomingVideoSourceBuffer = this.incomingVideoSource.addSourceBuffer(ev.data.type);
this.incomingVideoChunks.push(ev.data);
}
} else if (!this.incomingVideoSourceBuffer) {
this.incomingVideoChunks.push(ev.data);
} else {
this.incomingVideoChunks.push(ev.data);
this.incomingVideoChunks.splice(0, 1)[0].arrayBuffer().then(a => this.incomingVideoSourceBuffer.appendBuffer(a));
}
},
recordAudio: console.log.bind(null, "TBD: recordAudio"),
}
});
network:
name: test
server:
name: server001.test
listeners:
"127.0.0.1:6667":
max-sendq: 96k
datastore:
path: ircd.db
limits:
nicklen: 32
identlen: 20
channellen: 64
awaylen: 500
kicklen: 1000
topiclen: 1000
#!/bin/sh
: "${websrv:=github.com/korc/onefile-websrv}"
: "${ircsrv:=github.com/oragono/oragono}"
: "${http_addr:=:8080}"
: "${wd:=$(dirname "$(readlink -f "$0")")}"
set -x -e
test -e "ircd.yaml" || cp "$wd/ircd.yaml" .
go get $ircsrv && go run $ircsrv run & ircsrv_pid="$!"
trap "kill $ircsrv_pid; trap - EXIT" EXIT INT
go get $websrv && go run $websrv \
-listen "$http_addr" \
-map "/=file:$wd" \
-map "/ws/irc=ws:{type=text}127.0.0.1:6667"
<!DOCTYPE html>
<html>
<head>
<title>Media test</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
<script src="wschat.js"></script>
</head>
<body>
<div id="app">
<div v-if="chatMessages.length"
style="float: right; border: 1px dashed gray; padding: 0.5em; overflow: auto; max-height: 400px;" ref="chatlog">
<b>Chat</b> <button @click="chatMessages.splice(0)">&#128465</button>
<p v-for="msg,idx in chatMessages" :key="idx"><span @click="msg.sourceNick&&(chatTarget=msg.sourceNick)"
style="cursor: pointer;">{{msg.sourceNick || msg.source}}</span>&#8658;{{msg.parameters}}
</p>
</div>
<div v-if="!wschat||!wschat.connected">
Your nane: <input v-model="chatUser" placeholder="nickname" />
<span v-if="!hideSocket"><br />Socket: <input v-model="chatSocket" placeholder="wss://host/path" /></span>
<button :disabled="!chatUser || !!(wschat&&wschat.connected)" @click="connectChat">Connect</button>
<div class="error" v-if="connectErrors" style="border: 3px solid red; padding: 1em">
<h2>Failed to connect.</h2>
Perhaps specified websocket is not connected to a <a target="_blank"
href="https://github.com/oragono/oragono">IRC
daemon</a>?<br />
<p>Example:
<pre>
<a target="_blank" href="https://github.com/korc/onefile-websrv">onefile-websrv</a> -map /=file:"$PWD" -map "{{chatSocket.replace(/^[a-z]+:\/\/[^\/]+/,"")}}=websocket:{type=text}1.2.3.4:6667"
</pre>
</p>
Errors: {{connectErrors}}
</div>
</div>
<br />
<div v-if="!!wschat && wschat.connected && (wschat.joinedChannels.length===0||showJoin)">
Room: <input v-model="chatChannel" size="40" placeholder="#name">
<button :disabled="!wschat||!wschat.connected"
@click="wschat.send('JOIN '+chatChannel);showJoin=false">Join</button>
</div>
<div v-if="!!wschat && wschat.connected && wschat.joinedChannels.length>0">
Joined: <span v-for="ch,i in wschat.joinedChannels" :key="i">{{i>0?", ":""}}<span style="cursor: pointer;"
@click="chatTarget=ch.name">{{ch.name}}</span>
(<span v-for="u,j in ch.users" :key="j" style="cursor: pointer;"
@click="chatTarget=u">{{(j>0?", ":"")+u}}</span>)</span>
<button v-if="!showJoin" @click="showJoin=true">+</button><br />
To: <input :size="chatTarget.length+1" v-model="chatTarget" style="font-family: monospace" />
<input @keyup.enter.stop="sendChatMessage" :disabled="!chatTarget" v-model="chatMessage" size="80"
:placeholder="`Send message to ${chatTarget}..`" />
<br />
<select :disabled="!!videoStream" v-model="type">
<option value="getDisplayMedia">Screen</option>
<option value="getUserMedia">Webcam</option>
</select>
video to <input :disabled="!!videoStream" :size="videoTarget.length+1" v-model="videoTarget"
style="font-family: monospace" />
<button :disabled="!!videoStream||!videoTarget" @click="startVideo">Start</button>
<button :disabled="!videoStream" @click="stopVideo">Stop</button>
<br />
Record interval <input :disabled="!!videoStream" v-model="recordInterval" size="5" />
<!-- Audio BPS <input :disabled="!!videoStream" v-model.number="recordOptions.audioBitsPersecond" size="5" /> -->
Video BPS <input :disabled="!!videoStream" v-model.number="recordOptions.videoBitsPersecond" size="5" />
MIME Type <input :disabled="!!videoStream" v-model="recordOptions.mimeType" />
<!--
<br>
Audio:
<button :disabled="!!audioStream" @click="startAudio">Start</button>
<button :disabled="!audioStream" @click="stopAudio">Stop</button>
<button :disabled="!audioStream" @click="recordAudio">Record</button>
-->
</div>
<hr />
<div style="display: flex;">
<div v-show="!!videoStream">
<h3>Me</h3>
<video style="border: 1px solid red" width="640" autoplay ref="video-out"></video>
</div>
<div v-for="v,i in incomingStreams" :key="i">
<h3>{{v.sourceNick||v.source}}</h3>
<video :ref="`video-in-${v.source}`" style="border: 1px solid green" autoplay :src="v.url"></video>
</div>
<!-- <video style="border: 1px solid green" width="640" autoplay :src="incomingVideoURL"></video> -->
</div>
<div v-if="!!errors" class="error">Error: {{errors}} <button @click="errors=null">Dismiss</button></div>
</div>
<script src="chatapp.js"></script>
</body>
</html>
"use strict";
function WSChatMessage(text) {
if (typeof (text) === "string") this.parseText(text);
}
WSChatMessage.prototype = {
parseText(text) {
if (text.startsWith(":")) {
let spcIdx = text.indexOf(' ');
this.source = text.slice(1, spcIdx);
let nickSep = this.source.indexOf('!');
if (nickSep >= 0) this.sourceNick = this.source.slice(0, nickSep);
text = text.slice(spcIdx + 1);
}
let spcIdx = text.indexOf(' ');
this.command = text.slice(0, spcIdx);
this.parameters = text.slice(spcIdx + 1);
switch (this.command) {
case "PRIVMSG":
let spcIdx = this.parameters.indexOf(' ');
this.target = this.parameters.slice(0, spcIdx);
let nickSep = this.target.indexOf('!');
if (nickSep >= 0) this.targetNick = this.target.slice(0, nickSep);
this.msgIdx = spcIdx + 1;
break;
case "353":
let m = this.parameters.match(/^(\S+) ([@*=]) (\S+) :(.*)/);
if (m) {
this.myName = m[1];
this.mode = m[2];
this.channel = m[3];
this.users = m[4].split(" ");
}
}
}
}
function WSChat(addr, options) {
for (var opt in options)
this[opt] = options[opt];
this.connect(addr);
}
WSChat.prototype = {
connect(addr) {
var chatURL = new URL(addr, window.location.href);
if (chatURL.protocol === "https:") chatURL.protocol = "wss:";
else if (chatURL.protocol === "http:") chatURL.protocol = "ws:";
this.href = chatURL;
this.incomingBuffer = [];
this.joinedChannels = [];
this.commandHandlers = {
"221": [msg => (this.mySource = msg.source, this.myNick = msg.sourceNick)],
"353": [msg => this.joinedChannels.filter(ch => ch.name === msg.channel).forEach(ch => ch.users = msg.users)],
PING: [msg => { if (!msg.source) this.send("PONG " + msg.parameters) }],
JOIN: [msg => this.isMySource(msg) ? this.joinedChannels.push({ name: msg.parameters, users: [] }) : this.joinedChannels.forEach(ch => ch.users.push(msg.sourceNick))],
PART: [msg => {
let chName = msg.parameters.split(" ").shift();
if (this.isMySource(msg))
this.partedFrom(chName);
else
this.joinedChannels.filter(ch => ch.name === chName)
.forEach(ch => ch.users = ch.users.filter(u => u !== msg.sourceNick))
}],
KICK: [msg => this.partedFrom(msg.parameters.split(" ").shift())],
QUIT: [msg => this.joinedChannels.forEach(ch => ch.users = ch.users.filter(u => u !== msg.sourceNick))],
};
try { this.ws = new WebSocket(this.href, "text"); }
catch (e) {
this.onerror && this.onerror(e);
throw (e);
}
this.ws.onopen = () => {
this.connected = true;
this.onconnect && this.onconnect();
};
this.ws.onclose = () => {
this.connected = false;
this.onclose && this.onclose();
}
this.ws.onmessage = (ev) => {
this.incomingBuffer.push(ev.data);
this.processIncomingBuffer();
}
this.ws.onerror = (ev) => {
this.onerror && this.onerror(ev);
}
},
isMySource(msg, func) {
if (this.mySource === msg.source)
if (func) return func(msg);
else return true;
else
return false;
},
partedFrom(channel) {
let idx = this.joinedChannels.findIndex(ch => ch.name === channel);
if (idx >= 0) this.joinedChannels.splice(idx, 1);
},
send(msg) {
this.ws.send(msg + "\r\n");
},
login(nick, username, realname) {
this.send("NICK " + nick + "\r\n" + "USER " + (username || nick) + " 0 * :" + (realname || username || nick));
},
processIncomingBuffer() {
let lines = this.incomingBuffer.splice(0).join("").split("\r\n");
let lastLine = lines.pop();
if (lastLine.length > 0)
this.incomingBuffer.splice(0, 0, lastLine);
lines.forEach(message => {
message = new WSChatMessage(message);
this.processIncomingMessage(message);
this.onmessage && this.onmessage(message);
});
},
onCommand(cmd, func) {
if (!(cmd in this.commandHandlers))
this.commandHandlers[cmd] = [];
this.commandHandlers[cmd].push(func);
},
processIncomingMessage(msg) {
if (msg.command in this.commandHandlers)
this.commandHandlers[msg.command].forEach(cmd => cmd(msg));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment