This commit is contained in:
Leon van Kammen 2023-12-19 16:58:46 +01:00
parent f6132a29f4
commit d3b767b665
7 changed files with 961 additions and 810 deletions

View file

@ -6,11 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<script src="./../../../dist/xrfragment.aframe.js"></script>
<script src="https://cdn.jsdelivr.net/npm/aframe-blink-controls/dist/aframe-blink-controls.min.js"></script>
</head>
<body>
<script src="./../../../dist/xrfragment.aframe.js"></script>
<a-scene xr-mode-ui="XRMode: xr" renderer="colorManagement: true; highRefreshRate:true" light="defaultLightsEnabled: false">
<a-entity id="player" wasd-controls look-controls>
@ -28,15 +28,15 @@
</a-scene>
<script>
// add your logo
// the xrf-menu is totally optional but can be handy
// to quickly add your logo & buttons etc
XRFMENU.logo = './../../assets/logo.png'
XRFMENU.morelabel = '⚡'
// add your menubuttons:
XRFMENU.html.push(`<a class="btn" aria-label="button" aria-description="about menu" onclick="window.showAbout()">💁 about</a><br>`)
showAbout = () => window.notify(`
XRFMENU.buttons = XRFMENU.buttons.concat([`<a class="btn" aria-label="button" aria-description="about menu" onclick="XRFMENU.showAbout()">💁 about</a><br>`])
XRFMENU.showAbout = () => window.notify(`
<h1>💁 Hi there!</h1>
This XR fragments experience works almost anywhere.<br>
Allowing rich audiovisual events with(out)<br>
VR/AR headsets (it's awesome though ♥️)<br>

39
src/3rd/js/$.js Normal file
View file

@ -0,0 +1,39 @@
// this project uses #vanillajs #proxies #clean #noframework
//
// menu = $proxy({
// attach: 'body',
// html: (el) => `<ul><li>${el.items.join('</li><li>')}</li></lu>`,
// items: [1,2],
// on: (el,k,v) => {
// switch(k){
// default: return el.outerHTML = el.html(el)
// }
// }
// })
//
// menu.items = menu.items.concat([3,4])
// $('#foo')
// $$('.someclass').map( (el) => el.classList.toggle() )
//
$proxy = (opts) => {
let el = document.createElement('div')
el.innerHTML = opts.html(opts)
el.querySelector = el.querySelector.bind(el)
el.appendChild = el.appendChild.bind(el)
let parent = typeof opts.attach == 'string' ? document.querySelector(opts.attach) : opts.attach
parent.appendChild( el )
el.on = el.addEventListener.bind(el)
for( let i in opts ) el[i] = opts[i]
return new Proxy( el, {
get: (el,k,receiver) => el[k] || '',
set: (el,k,v) => { el[k] = v; el.on(el,k,v) }
})
}
$ = typeof $ != 'undefined' ? $ : (s) => document.querySelector(s) // respect jquery
$$ = typeof $$ != 'undefined' ? $$ : (s) => [...document.querySelectorAll(s)] // zepto etc.
$el = (html) => {
let el = document.createElement('div')
el.innerHTML = html
return el.children[0]
}

View file

@ -62,8 +62,6 @@ window.AFRAME.registerComponent('xrf', {
let url = opts.xrf.string
let isLocal = url.match(/^#/)
let hasPos = url.match(/pos=/)
let meeting = $('[meeting]') ? $('[meeting]').components['meeting'] : false
if(meeting) meeting.notifyTeleport(url)
if( isLocal && hasPos ){
// local teleports only
let fastFadeMs = 200

View file

@ -1,391 +1,391 @@
AFRAME.registerComponent('meeting', {
schema:{
id:{ required:true, type:'string'},
visitorname:{required:false,type:'string'},
parentRoom:{required:false,type:'string'},
link:{required:false,type:'string'}
},
remove: function(){
if( this.room ) this.room.leave()
this.meeting.remove()
},
update: function(){
setTimeout( () => {
this.remove()
this.init()
},100)
},
init: function(){
// embed https://github.com/dmotz/trystero (trystero-torrent.min.js build)
// add css+html
let el = this.meeting = document.createElement("div")
el.id = 'meeting'
el.innerHTML += `<style type="text/css">
#videos{
display:grid-auto-columns;
grid-column-gap:5px;
margin-bottom:15px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: 15px;
z-index:1500;
}
#videos > video{
border-radius:7px;
display:inline-block;
background:black;
width:80px;
height:60px;
margin-right:5px;
margin-bottom:5px;
vertical-align:top;
pointer-events:all;
}
#videos > video:hover{
filter: brightness(1.8);
cursor:pointer;
}
#chatbar,
button#showchat{
z-index: 1500;
position: fixed;
bottom: 20px;
left: 20px;
width: 48%;
background: white;
padding: 0px 0px 0px 15px;
border-radius: 30px;
max-width: 500px;
box-sizing: border-box;
box-shadow: 0px 0px 5px 5px #0002;
}
button#showchat{
z-index:1550;
color:white;
border:0;
display:none;
height: 44px;
background:#07F;
font-weight:bold;
}
#chatbar input{
border:none;
width:90%;
box-sizing:border-box;
}
#chat{
position: absolute;
top: 100px;
left: 0;
right: 0;
bottom: 88px;
padding: 15px;
pointer-events: none;
overflow-y:auto;
}
#chat .msg{
background: #fff;
display: inline-block;
padding: 6px 17px;
border-radius: 20px;
color: #000c;
margin-bottom: 10px;
line-height:23px;
pointer-events:visible;
border: 1px solid #ccc;
}
#chat .msg.info{
background: #333;
color: #FFF;
font-size: 14px;
padding: 0px 16px;
}
#chat .msg.info a,
#chat .msg.info a:visited{
color: #aaf;
text-decoration: none;
transition:0.3s;
}
#chat .msg.info a:hover,
#chat button:hover{
filter: brightness(1.8);
text-decoration: underline;
}
#chat button {
margin: 0px 15px 10px 0px;
background: #07F;
color: #FFF;
border-radius: 7px;
padding: 11px 15px;
border: 0;
font-weight: bold;
box-shadow: 0px 0px 5px 5px #0002;
pointer-events:all;
}
#chat,#chatbar,#chatbar *, #chat *{
font-family:monospace;
font-size:15px;
}
</style>
<div id="videos" style="pointer-events:none"></div>
<div id="chat" aria-live="assertive" aria-relevant></div>
<button id="showchat" class="btn">show chat</button>
<div id="chatbar">
<input id="chatline" type="text" placeholder="enter name"></input>
</div>`
document.body.appendChild(el)
let chatline = this.chatline = document.querySelector("#chatline")
let chat = this.chat = document.querySelector("#chat")
chat.log = [] // save raw chatlog to prime new visitors
chat.append = (str,classes,buttons) => this.chatAppend(str,classes,buttons)
this.initChatLine()
if( !this.data.visitorname ) this.chat.append("Please enter your name below",["info"])
else{
if( this.data.parentRoom ) this.chat.append(`leaving ${this.data.parentRoom}`,["info"]);
this.trysteroInit()
}
},
chatAppend: function(str,classes,buttons){
if( str ){
str = str.replace('\n', "<br>")
.replace(/^[a-zA-Z-0-9]+?[:\.]/,'<b>$&</b>')
let el = this.createMsg(str)
if( classes ) classes.map( (c) => el.classList.add(c) )
this.chat.appendChild(el) // send to screen
this.chat.innerHTML += '<br>'
if( !classes ) this.chat.log.push(str)
}
if( buttons ){
for( let i in buttons ){
let btn = document.createElement("button")
btn.innerText = i
btn.addEventListener('click', () => buttons[i]() )
this.chat.appendChild(btn)
}
this.chat.innerHTML += '<br>'
}
this.chat.scrollTop = this.chat.scrollHeight; // scroll to bottom
},
trysteroInit: async function(){
const { joinRoom } = await import("./../../../dist/trystero-torrent.min.js");
if( !document.location.hash.match(/meet/) ){
document.location.hash += document.location.hash.match(/#/) ? '&meet' : '#meet'
}
let roomname = this.roomname = this.data.link = document.location.href
const config = this.config = {appId: this.data.id }
const room = this.room = joinRoom(config, roomname )
this.chat.append("joined meeting at "+roomname,["info"]);
this.chat.append("copied meeting link to clipboard",["info"]);
const idsToNames = this.idsToNames = {}
const [sendName, getName] = room.makeAction('name')
const [sendChat, getChat] = this.room.makeAction('chat')
this.sendChat = sendChat
this.sendName = sendName
this.getChat = getChat
this.getName = getName
// tell other peers currently in the room our name
idsToNames[ room.selfId ] = this.data.visitorname.substr(0,15)
sendName( this.data.visitorname )
// listen for peers naming themselves
getName((name, peerId) => (idsToNames[peerId] = name))
this.initChat()
// this object can store audio instances for later
const peerAudios = this.peerAudios = {}
const peerVideos = this.peerVideos = {}
// get a local audio stream from the microphone
const selfStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: {
width: 320,
height: 240,
frameRate: {
ideal: 30,
min: 10
}
}
})
let meVideo = this.addVideo(selfStream, room.selfId)
meVideo.muted = true
// send stream to peers currently in the room
room.addStream(selfStream)
// send stream + chatlog to peers who join later
room.onPeerJoin( (peerId) => {
room.addStream(selfStream, peerId)
sendName( name, peerId)
this.sendChat({prime: this.chat.log}, peerId )
})
room.onPeerLeave( (peerId) => {
console.log(`${idsToNames[peerId] || 'a visitor'} left`)
if( peerVideos[peerId] ){
peerVideos[peerId].remove()
delete peerVideos[peerId]
}
delete idsToNames[peerId]
})
// handle streams from other peers
room.onPeerStream((stream, peerId) => {
// create an audio instance and set the incoming stream
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true
// add the audio to peerAudio object if you want to address it for something
// later (volume, etc.)
peerAudios[peerId] = audio
})
room.onPeerStream((stream, peerId) => {
this.addVideo(stream,peerId)
})
// show hide chat on small screens
},
addVideo: function(stream,peerId){
let video = this.peerVideos[peerId]
const videoContainer = document.getElementById('videos')
// if this peer hasn't sent a stream before, create a video element
if (!video) {
video = document.createElement('video')
video.autoplay = true
// add video element to the DOM
videoContainer.appendChild(video)
}
video.srcObject = stream
video.resize = (state) => {
if( video.resize.state == undefined ) video.resize.state = false
if( state == undefined ) state = (video.resize.state = !video.resize.state )
video.style.width = state ? '320px' : '80px'
video.style.height = state ? '200px' : '60px'
}
video.addEventListener('click', () => video.resize() )
this.peerVideos[peerId] = video
return video
},
createMsg: function(str){
let el = document.createElement("div")
el.className = "msg"
el.innerHTML = this.linkify(str)
return el
},
// central function to broadcast stuff to chat
send: function(str,classes,buttons){
if( !this.sendChat ) return
this.sendChat({content:str}) // send to network
this.chat.append(str,classes,buttons) // send to screen
},
initChatLine: function(){
let chatline = this.chatline = document.querySelector("#chatline")
chatline.addEventListener("keydown", (e) => {
if( e.key !== "Enter" ) return
if( !this.data.visitorname ){
this.data.visitorname = chatline.value.toLowerCase()
this.data.visitorname = this.data.visitorname.replace(/[^a-z]+/g,'-')
this.chat.append("note: camera/mic access is totally optional ♥️",["info"])
this.chatline.setAttribute("placeholder","chat here")
this.trysteroInit()
}else{
let str = `${this.idsToNames[ this.room.selfId ]}: ${chatline.value.substr(0,65515).trim()}`
this.send(str)
}
chatline.value = ''
event.preventDefault();
event.target.blur()
})
// on small screens/mobile make chat toggle-able
if( window.outerWidth < 1024 ){
let show = (state) => () => {
$('#chat').style.display = state ? '' : 'none'
$('#chatline').style.display = state ? '' : 'none'
$('button#showchat').style.display = state ? 'none' : 'block'
}
$('.a-canvas').addEventListener('click', show(false) )
$('.a-canvas').addEventListener('touchstart', show(false) )
$('#showchat').addEventListener('touchstart', show(true) )
$('#showchat').addEventListener('click', show(true) )
}
},
initChat: function(){
// listen for chatmsg
this.getChat((data, peerId) => {
if( data.prime ){
if( this.chat.primed ) return // only prime once
console.log("receiving prime")
data.prime.map( (l) => chat.append(l) ) // send log to screen
this.chat.primed = true
}
chat.append(data.content,data.classes,data.buttons) // send to screen
})
this.notifyTeleport()
return this
},
notifyTeleport: function(url,buttons){
// send to network
this.sendChat({
content: `${this.data.visitorname} teleported to ${this.roomname}`,
classes: ["info"],
buttons
})
},
linkify: function(t){
const m = t.match(/(?<=\s|^)[a-zA-Z0-9-:/]+[?\.][a-zA-Z0-9-].+?(?=[.,;:?!-]?(?:\s|$))/g)
if (!m) return t
const a = []
m.forEach(x => {
const [t1, ...t2] = t.split(x)
a.push(t1)
t = t2.join(x)
let y = (!(x.match(/:\/\//)) ? 'https://' : '') + x
let attr = 'target="_blank"'
if (isNaN(x) ){
let url_human = y.split('/')[2]
let isXRFragment = y.match(/\.(glb|gltf|obj|usdz|fbx|col)/)
if( isXRFragment ){ // detect xr fragments
isMeeting = y.match(/(#|&)meet/)
url_human = y.replace(/.*[?\?]/,'') // shorten xr fragment links
.replace(/[?\&]meet/,'')
y = y.replace(/.*[\?]/, '?') // start from search (to prevent page-refresh)
attr = ''
if( isMeeting ) attr = `onclick="$('[meeting]').components['meeting'].update()"`
}
a.push(`<a href="${y}" ${attr} style="pointer-events:all">${url_human}</a>`)
}else
a.push(x)
})
a.push(t)
return a.join('')
}
});
//
//AFRAME.registerComponent('meeting', {
// schema:{
// id:{ required:true, type:'string'},
// visitorname:{required:false,type:'string'},
// parentRoom:{required:false,type:'string'},
// link:{required:false,type:'string'},
// },
//
// // reactive HTML elements
// $: document.createElement('div'),
// cameras: {},
// audios: {},
// messages: [],
// placeholderInput: 'enter name here',
// hideChat: false,
// log: [],
// selfStream: null,
//
// events:{
// connect: function(e){
// if( !this.data.visitorname ){
// this.data.appendHTMLMessage("Please enter your name below",["info"])
// }else{
// if( this.data.parentRoom ) this.data.$chat.add(`leaving ${this.data.parentRoom}`,["info"]);
// }
// },
// },
//
// remove: function(){
// this.el.emit("remove",{})
// this.data.$.remove()
// },
//
// update: function(){
// setTimeout( () => {
// this.remove()
// this.initMeeting()
// },100)
// },
//
// init: function(){
//
// //// when teleport is clicked
// //AFRAME.XRF.addEventListener('href', (opts) => {
// // if( !opts.click ) return // ignore mouseovers etc
// // let url = opts.xrf.string
// // let isTeleport = url.match(/(:\/\/|pos=)/)
// // if( isTeleport ){
// // url = url[0] == '#' ? document.location.href.replace(/#.*/, opts.xrf.string ) : '?'+opts.xrf.string
// // this.notifyTeleport( url )
// // }
// //})
// this.initMeeting()
//
// },
//
// initMeeting: function(){
// this.data.link = document.location.href
// this.initHTML()
//
// // load plugins
// if( window.meeting.trystero ) new window.meeting.trystero(this.el,this,this.data)
//
// this.getMedia()
// this.emit("connect",{})
// },
//
// getMedia: async function(){
// this.data.selfStream = await navigator.mediaDevices.getUserMedia({
// audio: true,
// video: {
// width: 320,
// height: 240,
// frameRate: {
// ideal: 30,
// min: 10
// }
// }
// })
// let meVideo = this.createVideoElement(this.data.selfStream)
// meVideo.muted = true
// },
//
// // central function to broadcast stuff to chat
// send: function(opts){
// opts = { sendLocal: true, sendNetwork:true, ...opts }
// if( opts.sendNetwork ){
// if( !this.sendChat ) return
// this.sendChat({opts}) // send to network
// }
// if( opts.sendLocal ){
// this.data.$chat.add( opts ) // send to HTML screen
// }
// },
//
// createVideoElement: function(stream,id){
// let video = this.data.videos[peerId]
// const videoContainer = document.getElementById('videos')
// // if this peer hasn't sent a stream before, create a video element
// if (!video) {
// video = document.createElement('video')
// video.autoplay = true
//
// // add video element to the DOM
// videoContainer.appendChild(video)
// }
//
// video.srcObject = stream
// video.resize = (state) => {
// if( video.resize.state == undefined ) video.resize.state = false
// if( state == undefined ) state = (video.resize.state = !video.resize.state )
// video.style.width = state ? '320px' : '80px'
// video.style.height = state ? '200px' : '60px'
// }
// this.data.videos[ id || 'me' ] = video
// return video
// },
//
// initHTML: function(){
//
// // add css
// this.data.$.innerHTML += `<style type="text/css">
// #videos{
// display:grid-auto-columns;
// grid-column-gap:5px;
// margin-bottom:15px;
// position: fixed;
// top: 0;
// left: 0;
// bottom: 0;
// right: 0;
// margin: 15px;
// z-index:1500;
// }
// #videos > video{
// border-radius:7px;
// display:inline-block;
// background:black;
// width:80px;
// height:60px;
// margin-right:5px;
// margin-bottom:5px;
// vertical-align:top;
// pointer-events:all;
// }
// #videos > video:hover{
// filter: brightness(1.8);
// cursor:pointer;
// }
//
// #chatbar,
// button#showchat{
// z-index: 1500;
// position: fixed;
// bottom: 20px;
// left: 20px;
// width: 48%;
// background: white;
// padding: 0px 0px 0px 15px;
// border-radius: 30px;
// max-width: 500px;
// box-sizing: border-box;
// box-shadow: 0px 0px 5px 5px #0002;
// }
// button#showchat{
// z-index:1550;
// color:white;
// border:0;
// display:none;
// height: 44px;
// background:#07F;
// font-weight:bold;
// }
// #chatbar input{
// border:none;
// width:90%;
// box-sizing:border-box;
// }
// #chat{
// position: absolute;
// top: 100px;
// left: 0;
// right: 0;
// bottom: 88px;
// padding: 15px;
// pointer-events: none;
// overflow-y:auto;
// }
// #chat .msg{
// background: #fff;
// display: inline-block;
// padding: 6px 17px;
// border-radius: 20px;
// color: #000c;
// margin-bottom: 10px;
// line-height:23px;
// pointer-events:visible;
// border: 1px solid #ccc;
// }
// #chat .msg.info{
// background: #333;
// color: #FFF;
// font-size: 14px;
// padding: 0px 16px;
// }
// #chat .msg.info a,
// #chat .msg.info a:visited{
// color: #aaf;
// text-decoration: none;
// transition:0.3s;
// }
// #chat .msg.info a:hover,
// #chat button:hover{
// filter: brightness(1.8);
// text-decoration: underline;
// }
// #chat button {
// margin: 0px 15px 10px 0px;
// background: #07F;
// color: #FFF;
// border-radius: 7px;
// padding: 11px 15px;
// border: 0;
// font-weight: bold;
// box-shadow: 0px 0px 5px 5px #0002;
// pointer-events:all;
// }
// #chat,#chatbar,#chatbar *, #chat *{
// font-family:monospace;
// font-size:15px;
// }
// </style>
// <div id="videos" style="pointer-events:none"></div>
// <div id="chat" aria-live="assertive" aria-relevant></div>
// <div id="chatfooter"></div>
// `
// document.body.appendChild(this.data.$)
//
// // reactive logic
// this.data = new Proxy(this.data, {
// html: {
// chatbar: (data) => data.showChat
// ? `<div id="chatbar">
// <input id="chatline" type="text" placeholder="${data.placeholderInput}"></input>
// </div>`
// : `<button id="showchat" class="btn">show chat</button>`
// },
// get(data,k ){ return data[k] },
// set(data,k,v){
// data[k] = v
// switch( k ){
//
// case 'cameras':{
// data.$videos.innerHTML = ''
// for( let k in data.cameras ) el.appendChild( data.cameras[k] ) )
// return
// }
//
// case 'audios':{
// data.$videos.innerHTML = ''
// for( let k in data.cameras ) el.appendChild( data.cameras[k] ) )
// return
// }
//
// case 'showChat':{
// data.$chatfooter.innerHTML = this.html.chatbar(data)
// data.$chatline.addEventListener("keydown", this.onChatInput )
// if( v ){
// data.$chatfooter.querySelector('#chatline').focus()
// }
// return
// }
//
// }
// },
// })
//
// // trigger 1st render
// this.data.showChat = true
//
// // setup handles
// this.data.$videos = this.data.$.querySelector('#meeting #videos')
// this.data.$chatfooter = this.data.$.querySelector('#meeting #chatfooter')
// this.data.$chatline = this.data.$chatfooter.querySelector('#chatline')
// this.data.$chat = this.data.$.querySelector('#meeting #chat')
// this.data.$chat.add = this.addMessage
//
// },
//
// addMessage: function(opts){
// let {str,classes,buttons) = opts
// let el = this.data.$chat
//
// const createMsg = (str) => {
// let el = document.createElement("div")
// el.className = "msg"
// el.innerHTML = this.linkify(str)
// return el
// }
//
// if( str ){
// str = str.replace('\n', "<br>")
// .replace(/^[a-zA-Z-0-9]+?[:\.]/,'<b>$&</b>')
// let msg = this.createMsg(str)
// if( classes ) classes.map( (c) => msg.classList.add(c) )
// el.appendChild(msg) // send to screen
// el.innerHTML += '<br>'
// if( !classes ) this.data.log.push(str)
// }
// if( buttons ){
// for( let i in buttons ){
// let btn = document.createElement("button")
// btn.innerText = i
// btn.addEventListener('click', () => buttons[i]() )
// el.appendChild(btn)
// }
// el.innerHTML += '<br>'
// }
// el.scrollTop = el.scrollHeight; // scroll to bottom
// }
// },
//
// onChatInput: function(e){
// if( e.key !== "Enter" ) return
// let $chatline = this.data.$chatline
// if( !this.data.visitorname ){
// this.data.visitorname = chatline.value.toLowerCase()
// this.data.visitorname = this.data.visitorname.replace(/[^a-z]+/g,'-')
// this.send({message:"note: camera/mic access is totally optional ♥️", classes:["info"], isLocal:false})
// $chatline.setAttribute("placeholder","chat here")
// }else{
// let str = `${this.data.visitorname}: ${$chatline.value.substr(0,65515).trim()}`
// this.send({message:str})
// }
// $chatline.value = ''
// event.preventDefault();
// event.target.blur()
// },
//
// enableSmallScreen: function(){
// // on small screens/mobile make chat toggle-able
// if( window.outerWidth < 1024 ){
// let show = (state) => () => this.data.showChat = state
// $('.a-canvas').addEventListener('click', () => show(false) )
// $('.a-canvas').addEventListener('touchstart', () => show(false) )
// $('#showchat').addEventListener('touchstart', () => show(true) )
// $('#showchat').addEventListener('click', () => show(true) )
// }
// }
//
// //notifyTeleport: function(url){
// // url = url || this.roomname
// // url = url.replace(/(#|&)meet/,'')
// // let message = `${this.data.visitorname} teleported to ${url}`
// // this.send({
// // message,
// // classes: ["info"]
// // })
// //},
//
// //linkify: function(t){
// // const m = t.match(/(?<=\s|^)[a-zA-Z0-9-:/]+[?\.][a-zA-Z0-9-].+?(?=[.,;:?!-]?(?:\s|$))/g)
// // if (!m) return t
// // const a = []
// // m.forEach(x => {
// // const [t1, ...t2] = t.split(x)
// // a.push(t1)
// // t = t2.join(x)
// // let url = (!(x.match(/:\/\//)) ? 'https://' : '') + x
// // let attr = `href="${url}" target="_blank"`
// // if (isNaN(x) ){
// // let url_human = url.split('/')[2]
// // let isXRFragment = url.match(/\.(glb|gltf|obj|usdz|fbx|col)/)
// // if( isXRFragment ){ // detect xr fragments
// // isMeeting = url.match(/(#|&)meet/)
// // url_human = url.replace(/.*[?\?]/,'') // shorten xr fragment links
// // .replace(/[?\&]meet/,'')
// // let onclick = [ `xrf.navigator.to('${url}')` ]
// // if( isMeeting ) onclick.push(`$('[meeting]').coms['meeting'].update()`)
// // attr = `onclick="${onclick.join(';')}"`
// // }
// // a.push(`<a ${attr} style="pointer-events:all">${url_human}</a>`)
// // }else
// // a.push(x)
// // })
// // a.push(t)
// // return a.join('')
// //}
//
//})

View file

@ -0,0 +1,109 @@
//window.meeting = window.meeting||{}
//window.meeting.trystero = async function(el,com,data){
//
// // embed https://github.com/dmotz/trystero (trystero-torrent.min.js build)
// const { joinRoom } = await import("./../../../dist/trystero-torrent.min.js");
// this.room = {
// handle: null,
// link: null,
// selfId: null,
// names: {},
// chat: { send: null, get: null },
// name: { send: null, get: null },
// config: {appId: this.data.id }
// }
//
// this.sendName = null
//
// this.send = (opts) => com.send({...opts, source: 'trystero'})
//
// el.addEventListener('remove', () => {
// if( this.room.handle ) this.room.handle.leave()
// })
//
// el.addEventListener('connect', async () => {
// let room = this.room
//
// room.link = this.data.link
// if( !room.linkmatch(/(#|&)meet/) ){
// room.link = room.link.match(/#/) ? '&meet' : '#meet'
// }
// room.handle = joinRoom( room.config, room.link )
// room.selfId = room.handle.selfId
//
// this.send({
// message: "joined meeting at "+roomname.replace(/(#|&)meet/,''), // dont trigger init()
// classes: ["info"],
// sendNetwork:false
// })
//
// this.send({
// message:"copied meeting link to clipboard",
// classes: ["info"],
// sendNetwork:false
// })
//
// // setup trystero events
// const [sendName, getName] = room.makeAction('name')
// const [sendChat, getChat] = room.makeAction('chat')
// room.chat.send = sendChat
// room.chat.get = getChat
// room.name.send = sendName
// room.name.get = getName
//
// // tell other peers currently in the room our name
// room.names[ room.selfId ] = com.data.visitorname.substr(0,15)
// room.name.send( com.data.visitorname )
//
// // listen for peers naming themselves
// this.name.get((name, peerId) => (room.names[peerId] = name))
//
// // send self stream to peers currently in the room
// room.addStream(com.selfStream)
//
// // send stream + chatlog to peers who join later
// room.onPeerJoin( (peerId) => {
// room.addStream( com.selfStream, peerId)
// room.name.send( com.data.visitorname, peerId)
// room.chat.send({prime: com.log}, peerId )
// })
//
// room.onPeerLeave( (peerId) => {
// console.log(`${room.names[peerId] || 'a visitor'} left`)
// if( com.videos[peerId] ){
// com.videos[peerId].remove()
// delete com.videos[peerId]
// }
// delete room.names[peerId]
// })
//
// // handle streams from other peers
// room.onPeerStream((stream, peerId) => {
// // create an audio instance and set the incoming stream
// const audio = new Audio()
// audio.srcObject = stream
// audio.autoplay = true
// // add the audio to peerAudio object if you want to address it for something
// // later (volume, etc.)
// com.data.audios[peerId] = audio
// })
//
// room.onPeerStream((stream, peerId) => {
// com.createVideoElement(stream,peerId)
// })
//
// // listen for chatmsg
// room.chat.get((data, peerId) => {
// if( data.prime ){
// if( com.log.length > 0 ) return // only prime once
// console.log("receiving prime")
// data.prime.map( (l) => this.send({message:l, sendLocal:false ) ) // send log to screen
// this.chat.primed = true
// }
// this.send({ ...data, sendLocal: false}) // send to screen
// })
//
// }
//
//
//}

View file

@ -4,7 +4,7 @@ AFRAME.registerComponent('xrf-menu', {
},
init: function(){
// add css+html
window.XRFMENU.addHTML()
window.XRFMENU.init()
$('a-scene').addEventListener('XRF', this.onXRFready )
@ -17,6 +17,7 @@ AFRAME.registerComponent('xrf-menu', {
onXRFready: function(){
let XRF = window.AFRAME.XRF
return
XRFMENU.setupMenu( XRF )
// on localhost enable debugging mode for developer convenience

View file

@ -1,308 +1,8 @@
// handy shortcuts
if( !window.$ ) window.$ = (s) => s ? document.querySelector(s) : false
if( !window.$$ ) window.$$ = (s) => s ? [ ...document.querySelectorAll(s) ] : false
window.XRFMENU = {
logo: './../../assets/logo.png',
html: [
`<a class="btn" aria-label="button" aria-description="start text/audio/video chat" id="meeting" target="_blank">🧑‍🤝‍🧑 meeting</a><br>`,
`<a class="btn" aria-label="button" aria-description="share URL/screenshot/embed" id="share" target="_blank" onclick="window.share()">🔗 share</a><br>`
],
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();
}
},
setupMenu(XRF){
let aScene = document.querySelector('a-scene')
let urlbar = $('input#uri')
let inIframe = window.location !== window.parent.location
let els = [ ...document.querySelectorAll('.menu .btn') ]
els = els.filter( (el) => el.id != "more" ? el : false )
let showMenu = (state) => {
els.map( (el) => el.style.display = state ? 'inline-block' : 'none' )
$('a#more').style.display = state ? 'none' : 'inline-block'
$('#overlay').style.display = state ? 'inline-block' : 'none'
if( inIframe ) $('#uri').style.display = 'block'
}
els.map( (el) => el.addEventListener('click', () => showMenu(false) ) )
$('a#more').addEventListener('click', () => showMenu(true) )
$('.a-canvas').addEventListener('click', () => showMenu(false) )
// enable meetings
let startMeeting = () => {
aScene.setAttribute('meeting', 'id: xrfragments')
$('a#meeting').innerText = '🧑‍🤝‍🧑 breakout meeting'
$('a#meeting').setAttribute('aria-description','breakout room')
}
$('a#meeting').addEventListener('click', () => {
if( aScene.getAttribute('meeting') ){ // meeting already, start breakout room
let parentRoom = document.location.href
XRFMENU.updateHashPosition(true)
let meeting = $('[meeting]').components['meeting']
meeting.data.parentRoom = parentRoom
meeting.update()
}else startMeeting()
})
if( document.location.hash.match(/(#|&)meet/) ) startMeeting()
XRF.addEventListener('hash', () => reflectUrl() )
const reflectUrl = window.reflectUrl = (url) => {
urlbar.value = url || document.location.search.substr(1) + document.location.hash
}
reflectUrl()
},
SnackBar(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();
},
notify(scope){
return function notify(str,opts){
opts = opts || {status:'info'}
opts = Object.assign({ status, timeout:4000 },opts)
if( typeof str == 'string' ){
if( !opts.status ){
if( str.match(/error/g) ) opts.status = "danger"
if( str.match(/warning/g) ) opts.status = "warning"
}
}
opts.message = str
window.XRFMENU.SnackBar( 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(/#&/,'')
XRFMENU.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(){
let inMeeting = $('[meeting]')
let url = window.location.href
if( !inMeeting ) XRFMENU.updateHashPosition()
else url = $('[meeting]').components['meeting'].data.link
XRFMENU.copyToClipboard( url )
// End of *TODO*
window.notify(`<h2>${ inMeeting ? 'Meeting link ' : 'Link'} copied to clipboard!</h2> <br>Now share it with your friends ❤️<br>
<canvas id="qrcode" width="121" height="121"></canvas><br>
<button onclick="window.download()">💾 download scene file</button> <br>
<button onclick="alert('this might take a while'); $('a-scene').components.screenshot.capture('equirectangular')">📷 download 360 screenshot</button> <br>
<a class="btn" target="_blank" href="https://github.com/coderofsalvation/xrfragment-helloworld">🖥 clone & selfhost this experience</a><br>
<br>
To embed this experience in your blog,<br>
copy/paste the following into your HTML:<br><input type="text" value="&lt;iframe src='${document.location.href}'&gt;<br>&lt;/iframe&gt;" id="share"/>
<br>
`,{timeout:2000000})
// draw QR code
setTimeout( () => {
let QR = window.QR
QR.canvas = document.getElementById('qrcode')
QR.draw( url, QR.canvas )
},0)
}
}
window.XRFMENU.addHTML = () => {
let el = document.createElement("div")
el.innerHTML += `<style type="text/css">
$XRFMENU = $el(
`<div id="menu" x-bind="XRFMENU">
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" type="text/javascript"></script>
<style type="text/css">
:root {
--xrf-primary: #6839dc;
--xrf-primary-fg: #FFF;
@ -670,24 +370,40 @@ window.XRFMENU.addHTML = () => {
</style>
<div id="overlay" class="xrf" style="display:none">
<div class="logo"></div>
<div class="logo" :style="'background-image: url('+$store.XRFMENU.logo+')'"></div>
<button id="navback" onclick="history.back()">&lt;</button>
<button id="navforward" onclick="history.forward()">&gt;</button>
<input type="submit" value="load 3D file"></input>
<input type="text" id="uri" value="" onchange="AFRAME.XRF.navigator.to( $('#uri').value )" style="display:none"/>
</div>
<!-- open AFRAME inspector: $('a-scene').components.inspector.openInspector() -->
<div class="xrf footer">
<div id="buttons" class="menu">
${window.XRFMENU.html.map( (html) => typeof html == "function" ? html() : html ).join('\n')}
<a class="btn" id="more" style="display:inline-block">${window.XRFMENU.morelabel}</a>
<a class="btn" id="more" style="display:inline-block" x-text="$store.XRFMENU.morelabel"></a>
</div>
</div>
`
document.body.appendChild(el)
</div>`
)
//${window.XRFMENU.html.map( (html) => typeof html == "function" ? html() : html ).join('\n')}
if( XRFMENU.logo ) $('.logo').style['background-image'] = `url(${XRFMENU.logo})`
window.XRFMENU = {
$: $XRFMENU,
morelabel: '⚡',
enabled: false,
logo: './../../assets/logo.png',
buttons: [
`<a class="btn" aria-label="button" aria-description="start text/audio/video chat" id="meeting" target="_blank">🧑‍🤝‍🧑 meeting</a><br>`,
`<a class="btn" aria-label="button" aria-description="share URL/screenshot/embed" id="share" target="_blank" onclick="window.share()">🔗 share</a><br>`
],
init: () => {
// bind object as alpine app
document.body.appendChild( XRFMENU.$ )
document.addEventListener('alpine:init', () => Alpine.bind('XRFMENU', () => XRFMENU ) )
//if( XRFMENU.logo ) $('.logo').style['background-image'] = `url(${XRFMENU.logo})`
window.notify = XRFMENU.notify(window)
window.share = XRFMENU.share
window.download = XRFMENU.download
@ -703,5 +419,293 @@ window.XRFMENU.addHTML = () => {
window.open(event.data.url, '_blank');
}
});
},
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();
}
},
setupMenu(XRF){
let aScene = document.querySelector('a-scene')
let urlbar = $('input#uri')
let inIframe = window.location !== window.parent.location
let els = [ ...document.querySelectorAll('.menu .btn') ]
els = els.filter( (el) => el.id != "more" ? el : false )
let showMenu = (state) => {
els.map( (el) => el.style.display = state ? 'inline-block' : 'none' )
$('a#more').style.display = state ? 'none' : 'inline-block'
$('#overlay').style.display = state ? 'inline-block' : 'none'
if( inIframe ) $('#uri').style.display = 'block'
}
els.map( (el) => el.addEventListener('click', () => showMenu(false) ) )
$('a#more').addEventListener('click', () => showMenu(true) )
$('.a-canvas').addEventListener('click', () => showMenu(false) )
// enable meetings
let startMeeting = () => {
aScene.setAttribute('meeting', 'id: xrfragments')
$('a#meeting').innerText = '🧑‍🤝‍🧑 breakout meeting'
$('a#meeting').setAttribute('aria-description','breakout room')
}
$('a#meeting').addEventListener('click', () => {
if( aScene.getAttribute('meeting') ){ // meeting already, start breakout room
let parentRoom = document.location.href
XRFMENU.updateHashPosition(true)
let meeting = $('[meeting]').components['meeting']
meeting.data.parentRoom = parentRoom
meeting.update()
}else startMeeting()
})
if( document.location.hash.match(/(#|&)meet/) ) startMeeting()
XRF.addEventListener('hash', () => reflectUrl() )
const reflectUrl = window.reflectUrl = (url) => {
urlbar.value = url || document.location.search.substr(1) + document.location.hash
}
reflectUrl()
},
SnackBar(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();
},
notify(scope){
return function notify(str,opts){
opts = opts || {status:'info'}
opts = Object.assign({ status, timeout:4000 },opts)
if( typeof str == 'string' ){
if( !opts.status ){
if( str.match(/error/g) ) opts.status = "danger"
if( str.match(/warning/g) ) opts.status = "warning"
}
}
opts.message = str
window.XRFMENU.SnackBar( 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(/#&/,'')
XRFMENU.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(){
let inMeeting = $('[meeting]')
let url = window.location.href
if( !inMeeting ) XRFMENU.updateHashPosition()
else url = $('[meeting]').components['meeting'].data.link
XRFMENU.copyToClipboard( url )
// End of *TODO*
window.notify(`<h2>${ inMeeting ? 'Meeting link ' : 'Link'} copied to clipboard!</h2> <br>Now share it with your friends ❤️<br>
<canvas id="qrcode" width="121" height="121"></canvas><br>
<button onclick="window.download()">💾 download scene file</button> <br>
<button onclick="alert('this might take a while'); $('a-scene').components.screenshot.capture('equirectangular')">📷 download 360 screenshot</button> <br>
<a class="btn" target="_blank" href="https://github.com/coderofsalvation/xrfragment-helloworld">🖥 clone & selfhost this experience</a><br>
<br>
To embed this experience in your blog,<br>
copy/paste the following into your HTML:<br><input type="text" value="&lt;iframe src='${document.location.href}'&gt;<br>&lt;/iframe&gt;" id="share"/>
<br>
`,{timeout:2000000})
// draw QR code
setTimeout( () => {
let QR = window.QR
QR.canvas = document.getElementById('qrcode')
QR.draw( url, QR.canvas )
},0)
}
}