(function(){
// 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 ) )
})
connectionsComponent = {
html: `
`,
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';
if( !v && el.parentNode && el.parentNode.parentNode ) el.parentNode.parentNode.remove()
break;
case "webcam": $webcam.innerHTML = `${data[k].map((p)=>p.profile.name).join(' ')} `; break;
case "chatnetwork": $chatnetwork.innerHTML = `${data[k].map((p)=>p.profile.name).join(' ')} `; break;
case "scene": $scene.innerHTML = `${data[k].map((p)=>p.profile.name).join(' ')} `; 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('$chat: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 = `
`
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"),
$chatsend: el.querySelector("#chatsend"),
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"] })
},
sendInput(value){
if( value[0] == '#' ) return xrf.navigator.to(value)
let event = value.match(/^[!\/]/) ? "chat.command" : "network.send"
let message = value.replace(/^[!\/]/,'')
let raw = {detail:{message:value, halt:false}}
document.dispatchEvent( new CustomEvent( event, {detail: {message}} ) )
document.dispatchEvent( new CustomEvent( "chat.input", raw ) )
if( event == "network.send" && !raw.detail.halt ) this.send({message: value })
this.$chatline.lastValue = value
this.$chatline.value = ''
if( window.innerHeight < 600 ) this.$chatline.blur()
},
initListeners(){
let {$chatline} = this
$chatline.addEventListener('click', (e) => this.inform() )
$chatline.addEventListener('keydown', (e) => {
if (e.key == 'Enter' ){
this.sendInput($chatline.value)
}
if (e.key == 'ArrowUp' ){
$chatline.value = $chatline.lastValue || ''
}
})
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
})
document.addEventListener('chat.command', (e) => {
if( String(e.detail.message).trim() == 'help' ){
let detail = {message:`The following commands are available
/help shows this help screen
`}
document.dispatchEvent( new CustomEvent( 'chat.command.help', {detail}))
this.send({message: detail.message})
}
})
this.$chatsend.addEventListener('click', (e) => {
this.sendInput($chatline.value)
})
},
inform(){
if( !this.inform.informed && (this.inform.informed = true) ){
window.notify("You can now type messages in the textfield below.")
}
},
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)/) && !opts.from ){
let frag = xrf.URI.parse(document.location.hash).XRF
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 = `
`
window.debug = (opts) => new Proxy({
opts,
enabled: false,
$console: false,
toggle(){ this.enabled = !this.enabled },
settings(){
this.toggle()
},
init(){
},
setupConsole(){
// add onscreen console
let $console = this.$console = document.createElement('pre')
$console.style.position = 'fixed'
$console.style.overflow = 'auto'
$console.style.top = $console.style.left = $console.style.bottom = $console.style.right = '0px'
$console.style.height = '98.5vh';
$console.style.width = '100%'
$console.style.pointerEvents = 'none'
$console.id = 'console'
document.body.appendChild($console)
const wrapper = (scope, fn, name) => {
return function(msg) {
$console.innerHTML += `[${name}] ${msg} `;
if( name == 'err'){
let err = new Error()
String(err.stack).split("\n").slice(2).map( (l) => $console.innerHTML += ` └☑ ${l}\n` )
}
$console.scrollTop = $console.scrollHeight;
fn.call(scope,msg);
};
}
window.console.log = wrapper(console, console.log, "log");
window.console.warn = wrapper(console, console.warn, "wrn");
window.console.error = wrapper(console, console.error, "err");
}
},
{
// auto-trigger events on changes
get(data,k,receiver){ return data[k] },
set(data,k,v){
data[k] = v
switch( k ){
case "enabled": {
if( !data.$console ) data.setupConsole()
$('#debug.btn').style.filter= v ? 'brightness(1.0)' : 'brightness(0.5)'
data.$console.style.display = v ? 'block' : 'none'
data.enabled = v
}
}
}
})
document.addEventListener('$menu:ready', (e) => {
try{
debug = debug(e.detail)
debug.init()
document.dispatchEvent( new CustomEvent("debug:ready", e ) )
$menu.buttons = $menu.buttons.concat([` debug `])
}catch(e){console.error(e)}
})
document.querySelector('head').innerHTML += `
`
}).apply({})