UNPKG

librecast-live

Version:

Live Streaming Video Platform with IPv6 Multicast

991 lines (956 loc) 26.3 kB
const live = (function () { const components = []; let store = {}; let chat = {}; const lctx = new LIBRECAST.Context(); // eslint-disable-next-line no-unused-vars class Store { _state = {} event = {} get state () { return this._state } set state (newstate) { this._state = newstate } getItem = (key) => { if (this._state[key] === undefined && window.localStorage.getItem(key) !== null) { this._state[key] = window.localStorage.getItem(key) } return this._state[key] } mutate = (key, value, cacheLocal) => { console.log(`mutating ${key}`) this._state[key] = value if (cacheLocal === true) { window.localStorage.setItem(key, value) } this.publish('state.' + key) } publish = (event) => { console.log(`publish ${event}`) if (this.event[event] instanceof Event) { document.dispatchEvent(this.event[event]) } } subscribe = (event, listener) => { console.log(`subscribing to ${event}`) if (this.event[event] === undefined) { console.log('new event created: ' + event) this.event[event] = new Event(event) } document.addEventListener(event, listener) } unsubscribe = (event, listener) => { console.log(`unsubscribing from ${event}`) document.removeEventListener(event, listener) } } // eslint-disable-next-line no-unused-vars class Chat { constructor (lctx) { this.lctx = lctx this.sockets = {} this.channels = {} this.sockidx = {} this.lastmsgs = [] this.lastmsg = 0 store.subscribe('state.chat', this.chatStateUpdated) store.subscribe('state.activeChannel', this.changeChannel) } chatStateUpdated () { console.log('Chat.chatStateUpdated') } changeChannel = () => { console.log('Chat.changeChannel') if (this.channels[store.state.activeChannel] === undefined) { this.join(store.state.activeChannel) } } join = channelName => { this.lctx.onconnect.then(() => { const p = [] this.sockets[channelName] = new LIBRECAST.Socket(this.lctx) this.channels[channelName] = new LIBRECAST.Channel(this.lctx, channelName) p.push(this.sockets[channelName].oncreate) p.push(this.channels[channelName].oncreate) Promise.all(p).then(() => { console.log('socket and channel ready') this.sockidx[this.sockets[channelName].id] = channelName store.mutate(`chat.channels.${channelName}.status`, 'ready') this.channels[channelName].bind(this.sockets[channelName]) .then(() => { this.channels[channelName].join() }) this.sockets[channelName].listen(this.packetDecode) }) }) } isDuplicate = msg => { const hash = sodium.crypto_generichash(64, sodium.from_string(JSON.stringify(msg))) const hex = sodium.to_hex(hash) for (let i = 0; i < 100; i++) { if (this.lastmsgs[i] === undefined) break if (this.lastmsgs[i] === hex) { console.warn('suppressing duplicate message') return true } } console.log('hashed msg' + this.lastmsg) this.lastmsgs[this.lastmsg++] = hex if (this.lastmsg > 99) { this.lastmsg = 0 } return false } findUser = (channelName, key) => { console.log("searching for key '" + key + "'") return store.state.chat.channels[channelName].users.find(user => user.key === key) } packetDecode = pkt => { console.log('message received, decoding') const msg = new LIBRECAST.Message(pkt) const chatState = store.state.chat const decoder = new TextDecoder('utf-8') const newmsg = JSON.parse(decoder.decode(new Uint8Array(pkt.payload))) console.log('message received on socket ' + pkt.id) // de-duplicate msgs // we shouldn't be receiving duplicates, but as this is UDP, we might if (this.isDuplicate(newmsg)) { return } // messages must have user if (newmsg.user === undefined) { return } newmsg.received = Date.now() // identify channel for this socket const channelName = this.sockidx[pkt.id] console.log(`socket ${pkt.id} => channel '${channelName}'`) chatState.channels[channelName].msgs.push(newmsg) // append user to user list for channel if (newmsg.user.key !== undefined && !this.findUser(channelName, newmsg.user.key)) { chatState.channels[channelName].users.push(newmsg.user) } // update stats if (chatState.channels[channelName].bytin === undefined) { chatState.channels[channelName].bytin = 0 } chatState.channels[channelName].bytin += pkt.len store.mutate('chat', chatState) } send = (channelName, msgText) => { console.log(`${channelName}: '${msgText}'`) const user = { nick: store.state.nick, key: 1234 } const msg = { timestamp: Date.now(), username: store.state.nick, user, msg: msgText, id: this.lctx.token } const jstr = JSON.stringify(msg) this.channels[channelName].send(jstr) // update stats const chatState = store.state.chat if (chatState.channels[channelName].bytout === undefined) { chatState.channels[channelName].bytout = 0 } chatState.channels[channelName].bytout += jstr.length store.mutate('chat', chatState) } } class Component { componentDidMount = () => { } render = () => { const range = document.createRange() const frag = range.createContextualFragment(this.template) const sevents = [] // add all events we allow in components sevents.push('click') sevents.push('keypress') for (let child = frag.firstChild; child; child = child.nextElementSibling) { // component event handlers let tree = document.createTreeWalker(child, NodeFilter.SHOW_ELEMENT) for (let node = tree.currentNode; node; node = tree.nextNode()) { sevents.forEach(e => { if (node.attributes[e]) { node.addEventListener(e, (evt) => { this[node.attributes[e].value](evt) }) } }) }; // search fragment for more components before inserting const q = [] tree = document.createTreeWalker(child, NodeFilter.SHOW_ELEMENT) for (let node = tree.currentNode; node; node = tree.nextNode()) { components.filter(c => c.nodeName === node.nodeName) .forEach(c => { const component = new c.component() component.node = node component.parent = this component.props = []; [...node.attributes].forEach(a => { component.props[a.name] = a.value }) q.push(component) }) } while (q.length > 0) { const c = q.shift() console.log('rendering component: ' + c.nodeName) c.render() }; } const newnode = frag.firstChild this.node.replaceWith(...frag.children) this.node = newnode this.componentDidMount() } } class App extends Component { nodeName = 'APP'; constructor() { super(); } get template() { return `<div class="outerspace"> <header></header> <middle></middle> <footbox></footbox> </div> ` } } class Avatar extends Component { nodeName = 'AVATAR'; constructor() { super(); } get template() { //let html = `<img src="${this.props.src}" />`; let html = `<img class="avatar" src="/media/librestack.svg" height="30" width="30" />`; return html; } } class ChannelListItem extends Component { nodeName = 'CHANNELLISTITEM'; constructor() { super(); } switchChannel(e) { const chan = e.target.attributes.channel.value; store.mutate("activeChannel", chan); } get template() { const channel = this.props.channel; let classes = 'channellistitem'; if (store.state.activeChannel === channel) { classes += " active"; } if (chat.channels[channel] !== undefined) { classes += ' ready'; } return `<li class="${classes}" click="switchChannel" channel="${channel}">${channel}</li>`; } componentDidMount = (() => { store.subscribe(`state.chat.channels.${this.props.channel}.status`, this.render); }); } class ChannelList extends Component { nodeName = 'CHANNELLIST'; constructor() { super(); } get template() { let html = `<div class="channellist"> <h2>Channels</h2> <ul>`; //store.state.chat.channels.forEach(chan => { for (const [key, value] of Object.entries(store.state.chat.channels)) { html += `<channellistitem channel="${key}"></channellistitem>` }; html += `</ul></div>`; return html; } } class ChatBar extends Component { nodeName = 'CHATBAR'; constructor() { super(); } clear() { this.node.firstElementChild.value = ""; this.node.firstElementChild.focus(); } keypress(e) { if (e.keyCode === 13) { this.newMsg(); this.clear(); } } newMsg() { const str = he.encode(this.node.firstElementChild.value); const msgText = `<p>${str}</p>`; chat.send(store.state.activeChannel, msgText); } get template() { let html = `<div class="chatbar"> <input type="text" keypress="keypress" /> </div>`; return html; } } class ChatBox extends Component { nodeName = 'CHATBOX'; constructor() { super(); store.subscribe('state.activeChannel', this.render); store.subscribe('state.showStats', this.render); } close() { store.mutate('showChat', undefined); } get template() { let html = `<div class="chatbox"> <chattopic></chattopic> <section> <channellist></channellist> <chatpane></chatpane> <!--userlist></userlist-->`; if (store.state.showStats) { html += `<stats></stats>`; } html += `</section> </div>`; return html; } } class ChatMsgs extends Component { nodeName = 'CHATMSGS'; constructor() { super(); store.subscribe('state.chat', this.render); } componentDidMount = () => { this.node.scrollTop = this.node.scrollHeight; } get template() { let html = `<div class="chatmsgs">`; if (store.state.activeChannel !== undefined) { const chan = store.state.chat.channels[store.state.activeChannel]; const opt = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, }; chan.msgs.forEach(msg => { const sent = new Intl.DateTimeFormat('default', opt).format(msg.timestamp); html += `<div class="chatmsg">`; html += `<div class="msghdr">`; html += `<avatar src="/media/${msg.username}.jpg"></avatar>`; html += `<div class="username">${msg.username}</div>`; html += `<div class="timestamp">${sent}</div>`; html += `</div>`; html += `<section>${msg.msg}</section>`; html += `</div>`; }); } html += `</div>`; return html; } } class ChatPane extends Component { nodeName = 'CHATPANE'; constructor() { super(); store.subscribe('state.activeChannel', this.render); } get template() { console.log(store.state.activeChannel); let html = `<div class="chatpane"> <h2>#${store.state.activeChannel}</h2> <chatmsgs></chatmsgs> <chatbar></chatbar> </div>`; return html; } } class ChatTopic extends Component { nodeName = 'CHATTOPIC'; constructor() { super(); } get template() { let html = `<div class="chattopic"> Multicast Chat </div>`; return html; } } class CloseX extends Component { nodeName = 'CLOSEX'; constructor() { super(); } close() { console.log("closeX clicked"); store.mutate("closex", true); } get template() { return `<div class="closex" click="close">X</div>`; } } class Dialog extends Component { nodeName = 'DIALOG'; constructor() { super(); store.subscribe('state.closex', this.close); } close() { console.log("closex clicked"); } get template() { return `<div class="dialog"> <closex></closex> ${this.content} </div>`; } } class FootBox extends Component { nodeName = 'FOOTBOX'; get template() { return `<div class="footbox"> <p class="invisible">&nbsp;</p><!-- clear footer in text browsers --> </div>`; } } class Header extends Component { nodeName = 'HEADER'; constructor() { super(); } get template() { return `<div class="header"> <logobox></logobox> <projectbox></projectbox> <loginmenu></loginmenu> </div>`; } } class LoginMenu extends Component { nodeName = 'LOGINMENU'; connectButtons = ` <button class="login" click='showLogin' title="Log In"></button> `; logoutButtons = ` <button class="stats" click='stats' title="Stats"></button> <button class="logout" click='logout' title="Log Out"></button> <button class="profile" click='showProfile' title="Edit Profile"></button> `; constructor() { super(); store.subscribe('state.loggedIn', this.render); } logout() { console.log("logging out"); store.mutate('loggedIn', undefined); } stats() { store.mutate('showStats', !store.state.showStats); } showDialog(mode) { store.mutate('showSignup', mode); } showLogin() { this.showDialog('login'); } showProfile() { store.mutate('showProfile', 'profileGeneral'); } showSignup() { this.showDialog('signup'); } get template() { this.content = (store.state.loggedIn) ? this.logoutButtons : this.connectButtons; return `<div class="loginmenu"> ${this.content} </div>`; } } class LogoBox extends Component { nodeName = 'LOGOBOX'; constructor() { super(); } get template() { return `<div class="logobox"> <img class="logo" src="/media/live.svg" height="100" width="100" alt="" /> </div>`; } } class MenuBox extends Component { nodeName = 'MENUBOX'; constructor() { super(); } get template() { return `<div class="menubox"> <nav> <ul> <!-- <li><a href="/about.html">About</a></li> <li><a href="/code.html">Code</a></li> <li><a href="/roadmap.html">Roadmap</a></li> <li><a href="/team.html">Team</a></li> <li><a href="/videos.html">Videos</a></li> --> </ul> </nav> </div>`; } } class Middle extends Component { nodeName = 'MIDDLE'; constructor() { super(); store.subscribe('state.showSignup', this.render); store.subscribe('state.showProfile', this.render); store.subscribe('state.showChat', this.render); store.subscribe('state.loggedIn', this.render); } get template() { let html = `<div class="middle">`; if (store.state.showSignup !== undefined) { html += `<signupbox></signupbox>`; } if (store.state.loggedIn !== undefined && store.state.showChat !== undefined) { html += `<chatbox></chatbox>`; } if (store.state.loggedIn !== undefined && store.state.showProfile !== undefined) { html += `<profile></profile>`; } html += `</div>`; return html; } } class Profile extends Dialog { nodeName = 'PROFILE'; constructor() { super(); this.nick = store.getItem('nick'); this.bio = store.getItem('bio'); if (this.nick === undefined) { this.nick = ''; } if (this.bio === undefined) { this.bio = ''; } this.forms = { profileGeneral: ` <h1>Profile</h1> <div class="tabmenu"> <button click='showLogin' class="down">General</button> <button click='showSignup'>Particular</button> </div> <div class="dialogform"> <form action="javascript:void(0);"> <label for="nick">Nick</label> <input id="nick" type="text" placeholder="your user nick/handle" value="${this.nick}" /> <label for="bio">Bio</label> <input id="bio" type="text" placeholder="bio" value="${this.bio}" /> <button type="submit" click="save">Save Changes</button> </form> </div>` } this.content = this.forms[store.state.showProfile]; } close() { store.mutate('showProfile', undefined); if (store.state.nick !== undefined) { store.mutate('showChat', true); } } saveField(fieldName) { const value = document.getElementById(fieldName).value; store.mutate(fieldName, value, true); } save() { // TODO store transactions to batch updates? //TODO store.begin(); this.saveField("nick"); this.saveField("bio"); this.close(); //TODO store.commit(); //TODO store.abort(); } } class ProjectBox extends Component { nodeName = 'PROJECTBOX'; constructor() { super(); } get template() { return `<div class="projectbox"> <div class="projectname"> Libre<span class="latter">cast</span> <span class="itsalive">LIVE</span> </div> </div> </div>`; } } class SetPassword extends Component { nodeName = 'SETPASSWORD'; constructor() { super(); } get template() { return `<div class="setpassword"> <h1>Complete Account Signup</h1> <input id="password" type="password" /> <button type="submit" onclick="live.setPassword();">Sign Up</button> </div>`; } } class SignupBox extends Dialog { nodeName = 'SIGNUPBOX'; forms = { signup: ` <h1>Join Librecast LIVE!</h1> <div class="tabmenu"> <button click='showLogin'>Log In</button> <button click='showSignup' class="down">Sign Up</button> </div> <div class="dialogform"> <form action="javascript:void(0);"> <label for="emailAddress">Email Address</label> <input id="emailAddress" type="email" placeholder="you@example.com" /> <button type="submit" click="signup">Sign Up</button> </form> </div>` , login: ` <h1>Sign In to Librecast LIVE!</h1> <div class="tabmenu"> <button click='showLogin' class="down">Log In</button> <button click='showSignup'>Sign Up</button> </div> <div class="dialogform"> <form action="javascript:void(0);"> <label for="emailAddress">Email Address</label> <input id="emailAddress" type="email" placeholder="you@example.com" /> <label for="password">Password</label> <input id="password" type="password" /> <button type="submit" click="login">Log In</button> </form> </div>` , confirmSignup: ` <h1>Complete Account Signup</h1> <div class="dialogform"> <form action="javascript:void(0);"> <label for="password">Password</label> <input id="password" type="password" /> <button type="submit" click="setPassword">Sign Up</button> </form> </div>` , loggingIn: ` <h1>Logging In</h1> <p>Please wait a moment...</p>` , signedup: ` <h1>Sign Up Successful</h1> <p>You will receive an email with instructions to complete your signup.</p> ` } constructor() { super(); this.content = this.forms[store.state.showSignup]; } close() { store.mutate('showSignup', undefined); } async login() { const emailAddress = document.getElementById("emailAddress"); const password = document.getElementById("password"); store.mutate('showSignup', "loggingIn"); const s1 = Symbol('timeout') const timeout = new Promise((resolve, reject) => setTimeout(() => resolve(s1), 2000)) try { const login = live.login(emailAddress, password) const res = await Promise.race([login, timeout]) if (res === s1) { console.error('login TIMEOUT') store.mutate('showSignup', "login"); alert("Server Error: login timeout") } else { console.log("logged in successfully"); this.close(); store.mutate('loggedIn', true); if (store.getItem('nick') === undefined) { store.mutate('showProfile', 'profileGeneral'); } else { store.mutate('showChat', true); } } } catch(e) { console.error("login failed") store.mutate('showSignup', "login"); alert("login failed") } } showLogin() { store.mutate('showSignup', "login"); } showSignup() { store.mutate('showSignup', "signup"); } setPassword() { console.log("setting password"); const password = document.getElementById("password"); live.setPassword(store.state.passToken, password) .then(() => { alert("password set"); store.mutate('showSignup', "login"); }) .catch(() => { console.error("failed to set password"); alert("failed to set password"); }); } signup() { live.signup().then(() => { }) .catch(() => { console.error("signup failed"); alert("signup failed"); }); store.mutate('showSignup', "signedup"); } } class Stats extends Component { nodeName = 'STATS'; constructor() { super(); store.subscribe('state.chat', this.render); } userSelect(e) { const user = e.target.attributes.user.value; } get template() { const chan = store.state.chat.channels[store.state.activeChannel]; const bytin = (chan.bytin !== undefined) ? chan.bytin : 0; const bytout = (chan.bytout !== undefined) ? chan.bytout : 0; let html = `<div class="stats"> <h2>Statistics</h2> <dl> <div> <dt>bytes received</dt><dd>${bytin}</dd> </div> <div> <dt>bytes sent</dt><dd>${bytout}</dd> </div> </dl> </div>`; return html; } } class TopBar extends Component { nodeName = 'TOPBAR'; constructor() { super(); store.subscribe('state.title', this.render); } myfunc = () => { store.mutate('title', "Was Clicked"); } get template() { return `<div class="topbar" click="myfunc"> ${store.state.title} </div>`; } } class UserList extends Component { nodeName = 'USERLIST'; constructor() { super(); store.subscribe('state.chat', this.render); } userSelect(e) { const user = e.target.attributes.user.value; } get template() { let html = `<div class="userlist"> <h2>Users</h2> <ul>`; const chan = store.state.chat.channels[store.state.activeChannel]; chan.users.forEach(user => { html += `<li click="userSelect" user="${user.nick}"><avatar></avatar>${user.nick}</li>`; }); html += `</ul></div>`; return html; } } class VidBox extends Component { nodeName = 'VIDBOX'; constructor() { super(); } get template() { console.log(this.props); let html = `<div class="vidbox"><avatar></avatar></div>`; return html; } } class VidChat extends Component { nodeName = 'VIDCHAT'; constructor() { super(); } close() { store.mutate('showChat', undefined); } get template() { let html = `<div class="chat vidchat"> <chattopic></chattopic> <section> <vidbox></vidbox> <vidbox></vidbox> <chatpane></chatpane> </section> </div>`; return html; } } const authComboKeyHex = "fbdd352740551bd867f1970e7fc1a8fb23b0f84865beb7550aa51e5aa349e9271fd320c9db88bdbf249527b9720d90b30c03bdf0e06efe667e3e1e7e6f243c1f"; const login = function(emailAddress, password) { const kp = sodium.crypto_box_keypair(); let loggedin = false; return new Promise((resolve, reject) => { const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => { if (loggedin) return; loggedin = true; const view = new DataView(pre) const responseCode = view.getUint8(0).toString(); if (responseCode === "0") { resolve(); } else { reject(); } }); auth.ready.then(() => { auth.login(emailAddress.value, password.value); }); }); } const setPassword = function(token, password) { const kp = sodium.crypto_box_keypair(); let passet = false; return new Promise((resolve, reject) => { const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => { if (passet) return; passet = true; const view = new DataView(pre) const responseCode = view.getUint8(0).toString(); if (responseCode === "0") { resolve(); } else { reject(); } }) auth.ready.then(() => { auth.setPassword(token, password.value); }); }); } const signup = function() { const emailAddress = document.getElementById("emailAddress"); const kp = sodium.crypto_box_keypair(); let signup = false; return new Promise((resolve, reject) => { const auth = new Auth(lctx, authComboKeyHex, kp, (opcode, flags, fields, pre) => { if (signup) return; signup = true; const view = new DataView(pre) const responseCode = view.getUint8(0).toString(); if (responseCode === "0") { resolve(); } else { reject(); } }) auth.ready.then(() => { auth.signup(emailAddress.value, null); }); }); } const registerComponent = function(nodeName, component) { console.log(`registering ${nodeName} component`); components.push({ 'nodeName': nodeName, 'component': component }); } const render = function() { const tree = document.createTreeWalker(document.getRootNode(), NodeFilter.SHOW_ELEMENT); for (let node = tree.currentNode; node; node = tree.nextNode()) { const q = []; components.filter(c => c.nodeName === node.nodeName) .forEach(c => { console.log('rendering ' + c.nodeName); const component = new c.component(); component.node = node; q.push(component); }); while (q.length > 0) { const c = q.shift(); c.render(); }; } } const main = function() { store = new Store(); chat = new Chat(lctx); registerComponent('APP', App); registerComponent('AVATAR', Avatar); registerComponent('CHANNELLIST', ChannelList); registerComponent('CHANNELLISTITEM', ChannelListItem); registerComponent('CHATBAR', ChatBar); registerComponent('CHATBOX', ChatBox); registerComponent('CHATPANE', ChatPane); registerComponent('CHATMSGS', ChatMsgs); registerComponent('CHATTOPIC', ChatTopic); registerComponent('CLOSEX', CloseX); registerComponent('FOOTBOX', FootBox); registerComponent('HEADER', Header); registerComponent('LOGINMENU', LoginMenu); registerComponent('LOGOBOX', LogoBox); registerComponent('MENUBOX', MenuBox); registerComponent('MIDDLE', Middle); registerComponent('PROFILE', Profile); registerComponent('PROJECTBOX', ProjectBox); registerComponent('SETPASSWORD', SetPassword); registerComponent('SIGNUPBOX', SignupBox); registerComponent('STATS', Stats); registerComponent('USERLIST', UserList); registerComponent('VIDBOX', VidBox); registerComponent('VIDCHAT', VidChat); // load state fetch('/data/0001.state.json').then(response => { if (response.ok) { return response.json(); } }) .then(newstate => { store.mutate("activeChannel", newstate.activeChannel); store.mutate("chat", newstate.chat); }); const token = window.location.pathname.split("/")[2]; if (token !== undefined) { store.mutate('passToken', token); store.mutate('showSignup', "confirmSignup"); } render(); } return { main, signup, setPassword, login }; })(); live.main();