(function(){ chatComponent = { html: `
`, init: (el) => new Proxy({ scene: null, visible: true, messages: [], oneMessagePerUser: false, username: '', // configured by 'network.connected' event $videos: el.querySelector("#videos"), $messages: el.querySelector("#messages"), $chatline: el.querySelector("#chatline"), $chatbar: el.querySelector("#chatbar"), install(opts){ this.opts = opts this.scene = opts.scene this.$chatbar.style.display = 'none' el.className = "xrf" el.style.display = 'none' // start hidden document.body.appendChild( el ) document.dispatchEvent( new CustomEvent("$chat:ready", {detail: opts}) ) this.send({message:`Welcome to ${document.location.search.substr(1)}, a 3D scene(file) which simply links to other ones.
You can start a solo offline exploration in XR right away.
Type /help below, or use the arrow- or WASD-keys on your keyboard, and mouse-drag to rotate.
`, class: ["info","guide","multiline"] }) }, initListeners(){ let {$chatline} = this $chatline.addEventListener('click', (e) => this.inform() ) $chatline.addEventListener('keydown', (e) => { if (e.key == 'Enter' ){ if( $chatline.value[0] != '/' ){ document.dispatchEvent( new CustomEvent("network.send", {detail: {message:$chatline.value}} ) ) } this.send({message: $chatline.value }) $chatline.value = '' if( window.innerHeight < 600 ) $chatline.blur() } }) document.addEventListener('network.connect', (e) => { this.visible = true this.$chatbar.style.display = '' // show }) document.addEventListener('network.connected', (e) => { if( e.detail.username ) this.username = e.detail.username }) }, inform(){ if( !this.inform.informed && (this.inform.informed = true) ){ window.notify("Connected via P2P. You can now type message which will be visible to others.") } }, toggle(){ this.visible = !this.visible if( this.visible && window.meeting.status == 'offline' ) window.meeting.start(this.opts) }, hyphenate(str){ return String(str).replace(/[^a-zA-Z0-9]/g,'-') }, // sending messages to the #messages div // every user can post maximum one msg at a time // it's more like a 'status' which is more friendly // for accessibility reasons // for a fullfledged chat/transcript see matrix clients send(opts){ let {$messages} = this opts = { linebreak:true, message:"", class:[], ...opts } if( window.frontend && window.frontend.emit ) window.frontend.emit('$chat.send', opts ) opts.pos = opts.pos || network.posName || network.pos let div = document.createElement('div') let msg = document.createElement('div') let br = document.createElement('br') let nick = document.createElement('div') msg.className = "msg" let html = `${ opts.message || ''}${ opts.html ? opts.html(opts) : ''}` if( $messages.last == html ) return msg.innerHTML = html if( opts.el ) msg.appendChild(opts.el) opts.id = Math.random() if( opts.class ){ msg.classList.add.apply(msg.classList, opts.class) br.classList.add.apply(br.classList, opts.class) div.classList.add.apply(div.classList, opts.class.concat(["envelope"])) } if( !msg.className.match(/(info|guide|ui)/) ){ let frag = xrf.URI.parse(document.location.hash) opts.from = 'you' if( frag.pos ) opts.pos = frag.pos.string msg.classList.add('self') } if( opts.from ){ nick.className = "user" nick.innerText = opts.from+' ' div.appendChild(nick) if( opts.pos ){ let a = document.createElement("a") a.href = a.innerText = `#pos=${opts.pos}` nick.appendChild(a) } } div.appendChild(msg) // force one message per user if( this.oneMessagePerUser && opts.from ){ div.id = this.hyphenate(opts.from) let oldMsg = $messages.querySelector(`#${div.id}`) if( oldMsg ) oldMsg.remove() } // remove after timeout if( opts.timeout ) setTimeout( (div) => div.remove(), opts.timeout, div ) // finally add the message on top $messages.appendChild(div) if( opts.linebreak ) div.appendChild(br) $messages.scrollTop = $messages.scrollHeight // scroll down $messages.last = msg.innerHTML }, getChatLog(){ return ([...this.$messages.querySelectorAll('.envelope')]) .filter( (d) => !d.className.match(/(info|ui)/) ) .map( (d) => d.innerHTML ) .join('\n') } },{ get(me,k,v){ return me[k] }, set(me,k,v){ me[k] = v switch( k ){ case "visible": { el.style.display = me.visible ? 'block' : 'none' if( !el.inited && (el.inited = true) ) me.initListeners() break; } } } }) } // reactify component! document.addEventListener('$menu:ready', (opts) => { opts = opts.detail document.head.innerHTML += chatComponent.css window.$chat = document.createElement('div') $chat.innerHTML = chatComponent.html $chat = chatComponent.init($chat) $chat.install(opts) //$menu.buttons = ([`📜 toggle text
`]) // .concat($menu.buttons) }) // alpine component for displaying meetings chatComponent.css = ` ` connectionsComponent = { html: `

Webcam and/or Audio
Video
Mic
Audio
Networking a la carte:
Webcam
Chat
World sync
`, init: (el) => new Proxy({ visible: true, webcam: [{profile:{name:"No thanks"},config: () => document.createElement('div')}], chatnetwork: [{profile:{name:"No thanks"},config: () => document.createElement('div')}], scene: [{profile:{name:"No thanks"},config: () => document.createElement('div')}], selectedWebcam: '', selectedChatnetwork:'', selectedScene: '', $webcam: $webcam = el.querySelector("#webcam"), $chatnetwork: $chatnetwork = el.querySelector("#chatnetwork"), $scene: $scene = el.querySelector("#scene"), $settings: $settings = el.querySelector("#settings"), $devices: $devices = el.querySelector("#devices"), $connect: $connect = el.querySelector("#connect"), $networking: $networking = el.querySelector("#networking"), $audioInput: el.querySelector('select#audioInput'), $audioOutput: el.querySelector('select#audioOutput'), $videoInput: el.querySelector('select#videoInput'), install(opts){ this.opts = opts; (['change']).map( (e) => el.addEventListener(e, (ev) => this[e] && this[e](ev.target.id,ev) ) ) this.reactToNetwork() $menu.buttons = ([ ` connect
` ]).concat($menu.buttons) if( document.location.href.match(/meet=/) ) this.show() setTimeout( () => document.dispatchEvent( new CustomEvent("$connections:ready", {detail: opts}) ), 1 ) }, toggle(){ $chat.visible = !$chat.visible }, change(id,e){ if( id.match(/^(webcam|chatnetwork|scene)$/) ){ this.renderSettings() // trigger this when 'change' event fires on children dom elements } }, show(opts){ opts = opts || {} if( opts.hide ){ if( el.parentElement ) el.parentElement.parentElement.style.display = 'none' // hide along with wrapper elements if( !opts.showChat ) $chat.visible = false }else{ $chat.visible = true this.visible = true // hide networking settings if entering thru meetinglink $networking.style.display = document.location.href.match(/meet=/) ? 'none' : 'block' if( !network.connected ){ document.querySelector('body > .xrf').appendChild(el) $chat.send({message:"", el, class:['ui']}) if( !network.meetinglink ){ // set default $webcam.value = opts.webcam || 'Peer2Peer' $chatnetwork.value = opts.chatnetwork || 'Peer2Peer' $scene.value = opts.scene || 'Peer2Peer' } this.renderSettings() }else{ $chat.send({message:"you are already connected, refresh page to create new connection",class:['info']}) } } }, update(){ this.selectedWebcam = $webcam.value this.selectedChatnetwork = $chatnetwork.value this.selectedScene = $scene.value }, forSelectedPluginsDo(cb){ // this function looks weird but it's handy to prevent the same plugins rendering duplicate configurations let plugins = {} let select = (name) => (o) => o.profile.name == name ? plugins[ o.profile.name ] = o : '' this.webcam.find( select(this.selectedWebcam) ) this.chatnetwork.find( select(this.selectedChatnetwork) ) this.scene.find( select(this.selectedScene) ) for( let i in plugins ){ try{ cb(plugins[i]) }catch(e){ console.error(e) } } }, renderSettings(){ let opts = {webcam: $webcam.value, chatnetwork: $chatnetwork.value, scene: $scene.value } this.update() $settings.innerHTML = '' this.forSelectedPluginsDo( (plugin) => $settings.appendChild( plugin.config({...opts,plugin}) ) ) this.renderInputs() }, renderInputs(){ if( !this.selectedWebcam || this.selectedWebcam == 'No thanks' ){ return this.$devices.style.display = 'none' }else this.$devices.style.display = '' navigator.mediaDevices.getUserMedia({ audio: true, video: true }) .then( () => { const selectors = [this.$audioInput, this.$audioOutput, this.$videoInput]; const gotDevices = (deviceInfos) => { // Handles being called several times to update labels. Preserve values. const values = selectors.map(select => select.value); selectors.forEach(select => { while (select.firstChild) { select.removeChild(select.firstChild); } }); for (let i = 0; i !== deviceInfos.length; ++i) { const deviceInfo = deviceInfos[i]; const option = document.createElement('option'); option.value = deviceInfo.deviceId; if (deviceInfo.kind === 'audioinput') { option.text = deviceInfo.label || `microphone ${this.$audioInput.length + 1}`; this.$audioInput.appendChild(option); } else if (deviceInfo.kind === 'audiooutput') { option.text = deviceInfo.label || `speaker ${this.$audioOutput.length + 1}`; this.$audioOutput.appendChild(option); } else if (deviceInfo.kind === 'videoinput') { option.text = deviceInfo.label || `camera this.${this.$videoInput.length + 1}`; this.$videoInput.appendChild(option); } else { console.log('Some other kind of source/device: ', deviceInfo); } } // hide if there's nothing to choose let totalDevices = this.$audioInput.options.length + this.$audioOutput.options.length + this.$videoInput.options.length this.$devices.style.display = totalDevices > 3 ? 'block' : 'none' selectors.forEach((select, selectorIndex) => { if (Array.prototype.slice.call(select.childNodes).some(n => n.value === values[selectorIndex])) { select.value = values[selectorIndex]; } }); } // after getUserMedia we can enumerate navigator.mediaDevices.enumerateDevices().then(gotDevices).catch(console.warn); }) }, reactToNetwork(){ // *TODO* move to network? document.addEventListener('network.connect', () => { this.show({hide:true, showChat: true}) }) document.addEventListener('network.disconnect', () => { this.connected = false }) } },{ get(data,k,v){ return data[k] }, set(data,k,v){ data[k] = v switch( k ){ case "visible": el.style.display = v ? '' : 'none'; break; case "webcam": $webcam.innerHTML = ``; break; case "chatnetwork": $chatnetwork.innerHTML = ``; break; case "scene": $scene.innerHTML = ``; break; case "selectedScene": $scene.value = v; data.renderSettings(); break; case "selectedChatnetwork": $chatnetwork.value = v; data.renderSettings(); break; case "selectedWebcam": { $webcam.value = v; data.renderSettings(); $devices.style.display = v ? 'block' : 'none' break; } } } }) } // reactify component! document.addEventListener('$menu:ready', (opts) => { opts = opts.detail document.head.innerHTML += connectionsComponent.css window.$connections = document.createElement('div') $connections.innerHTML = connectionsComponent.html $connections = connectionsComponent.init($connections) $connections.install(opts) }) // alpine component for displaying meetings connectionsComponent.css = ` ` // reactive component for displaying the menu menuComponent = (el) => new Proxy({ html: ` `, collapsed: false, logo: './../../assets/logo.png', buttons: [` share
`], $buttons: $buttons = el.querySelector('#buttons'), $btnMore: $btnMore = el.querySelector('#more'), toggle(state){ this.collapsed = state !== undefined ? state : !this.collapsed el.querySelector("i#icon").className = this.collapsed ? 'gg-close' : 'gg-menu' document.body.classList[ this.collapsed ? 'add' : 'remove' ](['menu']) }, init(opts){ el.innerHTML = this.html document.body.appendChild(el); (['click']).map( (e) => el.addEventListener(e, (ev) => this[e] && this[e](ev.target.id,ev) ) ) setTimeout( () => { document.dispatchEvent( new CustomEvent("$menu:ready", {detail: {$menu:this,xrf}}) ) },100) return this }, click(id,e){ switch(id){ case "icon": case "more": this.toggle(); break; } } }, { get(me,k,v){ return me[k] }, set(me,k,v){ me[k] = v switch( k ){ case "buttons": el.querySelector("#buttons").innerHTML = this.renderButtons(me); document.dispatchEvent( new CustomEvent("$menu:buttons:render", {detail: el.querySelector('.menu') }) ) break; case "collapsed": el.querySelector("#buttons").style.display = me.collapsed ? 'block' : 'none' frontend.emit('$menu:collapse', v) break; } }, renderButtons: (data) => `${data.buttons.join('')}` }) // reactify component! document.addEventListener('frontend:ready', (e) => { window.$menu = menuComponent( document.createElement('div') ).init(e.detail) }) window.accessibility = (opts) => new Proxy({ opts, enabled: false, // features speak_movements: true, speak_keyboard: true, // audio settings speak_rate: 1, speak_pitch: 1, speak_volume: 1, speak_voice: -1, toggle(){ this.enabled = !this.enabled }, settings(){ this.toggle() // *TODO* should show settings screen }, speak(str, opts){ opts = opts || {speaksigns:true} if( !this.enabled || !str) return if( opts.speaksigns ){ str = str.replace(/\/\//,' ') .replace(/:/,'') .replace(/\//,' slash ') .replace(/\./,' dot ') .replace(/#/,' hash ') .replace(/&/,' and ') .replace(/=/,' is ') } let speech = window.speechSynthesis let utterance = new SpeechSynthesisUtterance( str ) if( this.speak_voice != -1) utterance.voice = speech.getVoices()[ this.speak_voice ]; else{ let voices = speech.getVoices() for(let i = 0; i < voices.length; i++ ){ if( voices[i].lang == navigator.lang ) this.speak_voice = i; } } utterance.rate = this.speak_rate utterance.pitch = this.speak_pitch utterance.volume = this.speak_volume if( opts.override ) speech.cancel() speech.speak(utterance) }, init(){ window.addEventListener('keydown', (e) => { if( !this.speak_keyboard ) return let k = e.key switch(k){ case "ArrowUp": k = "forward"; break; case "ArrowLeft": k = "left"; break; case "ArrowRight": k = "right"; break; case "ArrowDown": k = "backward"; break; } this.speak(k,{override:true}) }) document.addEventListener('$menu:buttons:render', (e) => { let $ = e.detail let a = [...$.querySelectorAll('a')] // make sure anchor buttons are accessible by tabbing to them a.map( (btn) => { if( !btn.href ) btn.setAttribute("href","javascript:void(0)") // important! btn.setAttribute("aria-label","button") }) document.addEventListener('mouseover', (e) => { if( e.target.getAttribute("aria-title") ){ let lines = [] lines.push( e.target.getAttribute("aria-title") ) lines.push( e.target.getAttribute("aria-description") ) lines = lines.filter( (l) => l ) this.speak( lines.join("."), {override:true,speaksigns:false} ) } }) }) document.addEventListener('network.send', (e) => { let opts = e.detail opts.message = opts.message || '' this.speak(opts.message) }) opts.xrf.addEventListener('pos', (opts) => { if( this.enabled ){ $chat.send({message: this.posToMessage(opts) }) network.send({message: this.posToMessage(opts), class:["info","guide"]}) } if( opts.frag.pos.string.match(/,/) ){ network.pos = opts.frag.pos.string }else{ network.posName = opts.frag.pos.string } }) }, posToMessage(opts){ let obj let description let msg = "teleported to " let pos = opts.frag.pos if( pos.string.match(',') ) msg += `coordinates ${pos.string}` else{ msg += `location ${pos.string}` obj = opts.scene.getObjectByName(pos.string) if( obj ){ description = obj.userData['aria-label'] || '' }else msg += ", but the teleportation was refused because it cannot be found within this world" } return msg }, sanitizeTranscript(){ return $chat.$messages.innerText .replaceAll("<[^>]*>", "") // strip html .split('\n') .map( (l) => String(l+'.').replace(/(^|:|;|!|\?|\.)\.$/g,'\$1') ) // add dot if needed .join('\n') } }, { // auto-trigger events on changes get(data,k,receiver){ return data[k] }, set(data,k,v){ data[k] = v switch( k ){ case "enabled": { let message = "accessibility has been"+(v?"boosted":"lowered") $('#accessibility.btn').style.filter= v ? 'brightness(1.0)' : 'brightness(0.5)' if( v ) $chat.visible = true $chat.send({message,class:['info','guide']}) data.enabled = true data.speak(message) data.enabled = v $chat.$messages.classList[ v ? 'add' : 'remove' ]('guide') if( !data.readTranscript && (data.readTranscript = true) ){ data.speak( data.sanitizeTranscript() ) } } } } }) document.addEventListener('$menu:ready', (e) => { try{ accessibility = accessibility(e.detail) accessibility.init() document.dispatchEvent( new CustomEvent("accessibility:ready", e ) ) $menu.buttons = $menu.buttons.concat([`accessibility
`]) }catch(e){console.error(e)} }) document.head.innerHTML += ` ` // this has some overlap with $menu.js // frontend serves as a basis for shared functions (download, share e.g.) window.frontend = (opts) => new Proxy({ html: `
`, el: null, plugin: {}, xrf, // this SUPER-emit forwards custom events to all objects supporting dispatchEvent // perfect to broadcast events simultaniously to document + 3D scene emit(k,v){ v = v || {event:k} for( let i in opts ){ if( opts[i].dispatchEvent ){ if( opts.debug ) console.log(`${i}.emit(${k},{...})`) opts[i].dispatchEvent( new CustomEvent(k,{detail:v}) ) } } }, init(){ // setup element and delegate events this.el = document.createElement("div") this.el.innerHTML = this.html document.body.appendChild(this.el); (['click']).map( (e) => this.el.addEventListener(e, (ev) => this[e] && this[e](ev.target.id,ev) ) ) this .setupFileLoaders() .setupIframeUrlHandler() .setupCapture() .setupUserHints() .setupNetworkListeners() .hidetopbarWhenMenuCollapse() .hideUIWhenNavigating() window.notify = this.notify setTimeout( () => { document.dispatchEvent( new CustomEvent("frontend:ready", {detail:opts} ) ) },1) return this }, click(id,ev){ switch( id ){ case "load": this.fileLoaders() } }, setupFileLoaders(){ // enable user-uploaded asset files (activated by load button) this.fileLoaders = this.loadFile({ ".gltf": (file) => file.arrayBuffer().then( (data) => xrf.navigator.to(file.name,null, (new xrf.loaders.gltf()), data) ), ".glb": (file) => file.arrayBuffer().then( (data) => xrf.navigator.to(file.name,null, (new xrf.loaders.gltf()), data) ) }) return this }, setupIframeUrlHandler(){ // allow iframe to open url window.addEventListener('message', (event) => { if (event.data && event.data.url) { window.open(event.data.url, '_blank'); } }); return this }, setupCapture(){ // add screenshot component with camera to capture bigger size equirects // document.querySelector('a-scene').components.screenshot.capture('perspective') $('a-scene').setAttribute("screenshot",{camera: "[camera]",width: 4096*2, height:2048*2}) return this }, setupUserHints(){ // notify navigation + href mouseovers to user setTimeout( () => { window.notify('loading '+document.location.search.substr(1)) setTimeout( () => window.notify("use WASD-keys and mouse-drag to move around",{timeout:false}),2000 ) setTimeout( () => xrf.addEventListener('href', (data) => data.selected ? window.notify(`href: ${data.xrf.string}`) : false ), 5000) },100) return this }, setupNetworkListeners(){ document.addEventListener('network.connect', (e) => { console.log("network.connect") window.notify("🪐 connecting to awesomeness..") $chat.send({message:`🪐 connecting to awesomeness..`,class:['info'], timeout:5000}) }) document.addEventListener('network.connected', (e) => { window.notify("🪐 connected to awesomeness..") $chat.visibleChatbar = true $chat.send({message:`🎉 ${e.detail.plugin.profile.name||''} connected!`,class:['info'], timeout:5000}) }) document.addEventListener('network.disconnect', () => { window.notify("🪐 disconnecting..") }) document.addEventListener('network.info', (e) => { window.notify(e.detail.message) $chat.send({...e.detail, class:['info'], timeout:5000}) }) document.addEventListener('network.error', (e) => { window.notify(e.detail.message) $chat.send({...e.detail, class:['info'], timeout:5000}) }) return this }, hidetopbarWhenMenuCollapse(){ // hide topbar when menu collapse button is pressed document.addEventListener('$menu:collapse', (e) => this.el.querySelector("#topbar").style.display = e.detail === true ? 'block' : 'none') return this }, hideUIWhenNavigating(){ // hide ui when user is navigating the scene using mouse/touch let showUI = (show) => (e) => { let isChatMsg = e.target.closest('.msg') let isChatLine = e.target.id == 'chatline' let isChatEmptySpace = e.target.id == 'messages' let isUI = e.target.closest('.ui') //console.dir({class: e.target.className, id: e.target.id, isChatMsg,isChatLine,isChatEmptySpace,isUI, tagName: e.target.tagName}) if( isUI || e.target.tagName.match(/^(BUTTON|TEXTAREA|INPUT|A)/) || e.target.className.match(/(btn)/) ) return if( show ){ $chat.visible = true }else{ $chat.visible = false $menu.toggle(false) } return true } document.addEventListener('mousedown', showUI(false) ) document.addEventListener('mouseup', showUI(true) ) document.addEventListener('touchstart', showUI(false) ) document.addEventListener('touchend', showUI(true) ) }, loadFile(contentLoaders, multiple){ return () => { window.notify("if you're on Meta browser, file-uploads might be disabled") let input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.accept = Object.keys(contentLoaders).join(","); input.onchange = () => { let files = Array.from(input.files); let file = files.slice ? files[0] : files for( var i in contentLoaders ){ let r = new RegExp('\\'+i+'$') if( file.name.match(r) ) return contentLoaders[i](file) } alert(file.name+" is not supported") }; input.click(); } }, notify(_str,opts){ if( window.outerWidth < 800 ) return if( window.accessibility && window.accessibility.enabled ) return $chat.send({message:_str,class:['info']}) opts = opts || {status:'info'} opts = Object.assign({ status, timeout:4000 },opts) opts.message = _str if( typeof str == 'string' ){ str = _str.replace(/(^\w+):/,"
\$1
") if( !opts.status ){ if( str.match(/error/g) ) opts.status = "danger" if( str.match(/warning/g) ) opts.status = "warning" } opts.message = str } window.SnackBar( opts ) opts.message = typeof _str == 'string' ? _str : _str.innerText window.frontend.emit("notify",opts) }, download(){ function fetchAndDownload(dataurl, filename) { var a = document.createElement("a"); a.href = dataurl; a.setAttribute("download", filename); a.click(); return false; } let file = document.location.search.replace(/\?/,'') fetchAndDownload( file, file ) }, updateHashPosition(randomize){ // *TODO* this should be part of the XRF Threejs framework if( typeof THREE == 'undefined' ) THREE = xrf.THREE let radToDeg = THREE.MathUtils.radToDeg let toDeg = (x) => x / (Math.PI / 180) let camera = document.querySelector('[camera]').object3D.parent // *TODO* fix for threejs camera.position.x += Math.random()/10 camera.position.z += Math.random()/10 // *TODO* add camera direction let direction = new xrf.THREE.Vector3() camera.getWorldDirection(direction) const pitch = Math.asin(direction.y); const yaw = Math.atan2(direction.x, direction.z); const pitchInDegrees = pitch * 180 / Math.PI; const yawInDegrees = yaw * 180 / Math.PI; let lastPos = `pos=${camera.position.x.toFixed(2)},${camera.position.y.toFixed(2)},${camera.position.z.toFixed(2)}` let newHash = document.location.hash.replace(/[&]?(pos|rot)=[0-9\.-]+,[0-9\.-]+,[0-9\.-]+/,'') newHash += `&${lastPos}` document.location.hash = newHash.replace(/&&/,'&') .replace(/#&/,'') this.copyToClipboard( window.location.href ); }, copyToClipboard(text){ // copy url to clipboard var dummy = document.createElement('input') document.body.appendChild(dummy); dummy.value = text; dummy.select(); document.execCommand('copy'); document.body.removeChild(dummy); }, share(opts){ opts = opts || {notify:true,qr:true,share:true,linkonly:false} if( network.meetingLink && !document.location.hash.match(/meet=/) ){ document.location.hash += `&meet=${network.meetingLink}` } if( !document.location.hash.match(/pos=/) ){ document.location.hash += `&pos=${ network.posName || network.pos }` } let url = window.location.href if( opts.linkonly ) return url this.copyToClipboard( url ) // End of *TODO* if( opts.notify ){ window.notify(`

${ network.connected ? 'Meeting link ' : 'Link'} copied to clipboard!

Now share it with your friends ❤️



   clone & selfhost this experience
To embed this experience in your blog,
copy/paste the following into your HTML:


`,{timeout:false}) } // draw QR code if( opts.qr ){ setTimeout( () => { let QR = window.QR QR.canvas = document.getElementById('qrcode') QR.draw( url, QR.canvas ) },1) } // mobile share if( opts.share && typeof navigator.share != 'undefined'){ navigator.share({ url, title: 'your meeting link' }) } $menu.collapse = true } }, { // auto-trigger events on changes get(me,k,receiver){ return me[k] }, set(me,k,v){ let from = me[k] me[k] = v switch( k ){ case "logo": $logo.style.backgroundImage = `url(${v})`; break; default: me.emit(`me.${k}.change`, {from,to:v}); break; } } }) frontend = frontend({xrf,document}).init() // this orchestrates multiplayer events from the scene graph window.network = (opts) => new Proxy({ connected: false, pos: '', posName: '', meetinglink: "", peers: {}, plugin: {}, opts, init(){ document.addEventListener('network.disconnect', () => this.connected = false ) document.addEventListener('network.connected', () => this.connected = true ) setTimeout( () => window.frontend.emit('network.init'), 100 ) return this }, connect(opts){ window.frontend.emit(`network.${this.connected?'disconnect':'connect'}`,opts) }, add(peerid,data){ data = {lastUpdated: new Date().getTime(), id: peerid, ...data } this.peers[peerid] = data window.frontend.emit(`network.peer.add`,{peer}) }, remove(peerid,data){ delete this.peers[peerid] window.frontend.emit(`network.peer.remove`,{peer}) }, send(opts){ window.frontend.emit('network.send',opts) }, receive(opts){ }, getMeetingFromUrl(url){ let hash = url.replace(/.*#/,'') let parts = hash.split("&") let meeting = '' parts.map( (p) => { if( p.split("=")[0] == 'meet' ) meeting = p.split("=")[1] }) return meeting }, randomRoom(){ var names = [] let add = (s) => s.length < 6 && !s.match(/[0-9$]/) && !s.match(/_/) ? names.push(s) : false for ( var i in window ) add(i) for ( var i in Object.prototype ) add(i) for ( var i in Function.prototype ) add(i) for ( var i in Array.prototype ) add(i) for ( var i in String.prototype ) add(i) var a = names[Math.floor(Math.random() * names.length)]; var b = names[Math.floor(Math.random() * names.length)]; return String(`${a}-${b}-${String(Math.random()).substr(13)}`).toLowerCase() } }, { // auto-trigger events on changes get(data,k,receiver){ return data[k] }, set(data,k,v){ let from = data[k] data[k] = v } }) document.addEventListener('frontend:ready', (e) => { window.network = network(e.detail).init() document.dispatchEvent( new CustomEvent("network:ready", e ) ) }) // a portable snackbar window.SnackBar = function(userOptions) { var snackbar = this || (window.snackbar = {}); var _Interval; var _Message; var _Element; var _Container; var _OptionDefaults = { message: "Operation performed successfully.", dismissible: true, timeout: 7000, status: "" } var _Options = _OptionDefaults; function _Create() { _Container = document.querySelector(".js-snackbar-container") if( _Container ){ _Container.remove() } _Container = null if (!_Container) { // need to create a new container for notifications _Container = document.createElement("div"); _Container.classList.add("js-snackbar-container"); document.body.appendChild(_Container); } _Container.opts = _Options _Container.innerHTML = '' _Element = document.createElement("div"); _Element.classList.add("js-snackbar__wrapper","xrf"); let innerSnack = document.createElement("div"); innerSnack.classList.add("js-snackbar", "js-snackbar--show"); if (_Options.status) { _Options.status = _Options.status.toLowerCase().trim(); let status = document.createElement("span"); status.classList.add("js-snackbar__status"); if (_Options.status === "success" || _Options.status === "green") { status.classList.add("js-snackbar--success"); } else if (_Options.status === "warning" || _Options.status === "alert" || _Options.status === "orange") { status.classList.add("js-snackbar--warning"); } else if (_Options.status === "danger" || _Options.status === "error" || _Options.status === "red") { status.classList.add("js-snackbar--danger"); } else { status.classList.add("js-snackbar--info"); } innerSnack.appendChild(status); } _Message = document.createElement("span"); _Message.classList.add("js-snackbar__message"); if( typeof _Options.message == 'string' ){ _Message.innerHTML = _Options.message; }else _Message.appendChild(_Options.message) innerSnack.appendChild(_Message); if (_Options.dismissible) { let closeBtn = document.createElement("span"); closeBtn.classList.add("js-snackbar__close"); closeBtn.innerText = "\u00D7"; closeBtn.onclick = snackbar.Close; innerSnack.appendChild(closeBtn); } _Element.style.height = "0px"; _Element.style.opacity = "0"; _Element.style.marginTop = "0px"; _Element.style.marginBottom = "0px"; _Element.appendChild(innerSnack); _Container.appendChild(_Element); if (_Options.timeout !== false) { _Interval = setTimeout(snackbar.Close, _Options.timeout); } } snackbar.Open = function() { let contentHeight = _Element.firstElementChild.scrollHeight; // get the height of the content _Element.style.height = contentHeight + "px"; _Element.style.opacity = 1; _Element.style.marginTop = "5px"; _Element.style.marginBottom = "5px"; _Element.addEventListener("transitioned", function() { _Element.removeEventListener("transitioned", arguments.callee); _Element.style.height = null; }) } snackbar.Close = function () { if (_Interval) clearInterval(_Interval); let snackbarHeight = _Element.scrollHeight; // get the auto height as a px value let snackbarTransitions = _Element.style.transition; _Element.style.transition = ""; requestAnimationFrame(function() { _Element.style.height = snackbarHeight + "px"; // set the auto height to the px height _Element.style.opacity = 1; _Element.style.marginTop = "0px"; _Element.style.marginBottom = "0px"; _Element.style.transition = snackbarTransitions requestAnimationFrame(function() { _Element.style.height = "0px"; _Element.style.opacity = 0; }) }); setTimeout(function() { try { _Container.removeChild(_Element); } catch (e) { } }, 1000); }; _Options = { ..._OptionDefaults, ...userOptions } _Create(); snackbar.Open(); } document.head.innerHTML += ` ` }).apply({})