My 360 Mind Place Web App. For Caputuring Immersive VR Moments. Free Open Source Code!

Michael McAnally
19 min readMay 14, 2022


A 360 degree video of me playing pinball and getting a free replay at my favorite arcade. Rotate your 360 degree view by dragging with mouse in center of the video to better see the arcade game. Unfortunately the reflection of the glass on the pinball machine is distracting. But I’m still the 360 pinball wizard!

Article prerequsites: An understanding of HTML, vanilla JavaScript, use of Photoshop, how to edit videos and software code, the necessary hardware to capture 360 degree images, access to a https:// enabled webserver, able to follow detailed instructions.

I’ve have had a 360 degree camera for a while now. Also, I have a Oculus Quest 2 VR headset (or now Meta Quest 2).

Combining these two pieces of hardware with a tripod, some long video processing time, a webserver, some open source A-frame WebXR, and it is possible to fully capture immersive 360 experiences in virtual reality over the internet. And even share them with others.

Places I sometimes hang out. Noisebridge 360 hacker space.

Moments whch can be replayed, memories recalled. I call it Mind Place because it helps me remember important events, video logs, and expereinces in my mind and its a data structure in itself, which I am building up slowly over time.

It is similar to a Mind Palace, but not for remembering a list of items, more for remembering experiences. Which I believe to ultimately be more important.

Just like our 2D photos, 2D home movies, etc. But in 360 degrees (3D), giving a viewer a fully immersive you are there, on your face kind of experience with a VR headset.

It makes sense because we are primarily visual creatures. I mean of all of our five senses, seeing is a protected part of our cognitive processing near the back of our brains, but associated with other memories and thoughts throughout the whole processing organ.

As an example, for me remembering the beach brings back memories of tide pools, a trip I took in elementary school, star fish, the boardwalk in Santa Cruz, the thrill of riding a roller coaster, driving down the coast of California, stoping at spots along the way, picking strawberies at a farm along the coast, and so on, and so on. I believe immersively triggered memories and experiences can be some of the most powerful and complete.

Personal 360 VR logs and 360 images I record in memorable places. This one is Shakespeare Garden Golden Gate Park.

I’m going to share my HTML code and JavaScript with you in this article which will allow you to create your own customized Mind Palace App, if you so choose and share it with friends. You will ofcourse need all the things (hardware) I mentioned above, plus a server with https SSL to post the files to.

A friend and I attached a 360 camera to his drone and flew it over the house. I added a no royalty sound track to replace the drone noise. It originally sounded like a hornet/bee hive and it was windy that day, so some shaking was happening, also the camera was a little heavy for the drone. We stayed within the law limits of flight height allowed for the residential area. Rotate your 360 degree view by dragging with mouse in center of the video to better see landscape and drome blades above. You can also see the shadow of the drone on the ground.

So this is a real project that will take some significant effort on your part. However, I promise you it is worth every moment and it will teach you so many things; leaving you with some real gems of personal experience for posterity.

You can also upload your 360 videos to youtube and share those, as I have done in parts of this article. However, having a seperate store of your own personal data in a format you own and have complete control over is far superior, in my opinion.

Justin demos his custom build super fast powerful skateboard on a 360 video ride. Rotate your 360 degree view by dragging with mouse in center of the video.

The real plus is that this can be shared with others by just sending people a URL link on social media or an email. Viewable on a smart phones, but much better on a computer. Chrome, Firefox or very best in the Meta Quest browser, a highly recommended experience.

Example index menu to Mind Place 360 Web App

Ultimately this is one way to build static metaverse like expeiences which can be recorded for all time. Or atleast as long as you keep them hosted. It is also possible to archieve and backup the entire structure for offline preservation later. I expect my software to evolve and improve significantly over time; as the experiences grow and become richer. Change is a quality of life, so nothing is coded in stone.

Please note: Be patient! That is because this data is coming over the internet and not preloaded as in a pre-game install. It will take some time to load. 360 data is much larger than 2D data. So imagine a cube with 6 sides, top, bottom and all around. Or imagine an additional axis in the Cartesian coordinate system in three dimensions, so not just x, y, but z-axis as well. The fortunate thing is that caching will occur in the browser after your first load, so impovement of revisitation loading times should actually occur if your browser can cache large file sizes. Otherwise reduce the number of 360 images being downloaded inside each HTML page of the web app to improve the over all wait time for the user experience. I found that eight and less 360 images is about the longest time people are willing to wait for a page to load. Having a fast internet or “intranet/LAN connection” really helps improve the experience.


Enough of the preliminaries. Now on to the source code and preperation process.

<!DOCTYPE html>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,shrink-to-fit=no,user-scalable=no,maximum-scale=1,minimum-scale=1">
<title>Mind Place 360 - VR space loading, please wait</title>
<meta name="description" content="This is a 360 degree photo and video structured user interface for VR headsets and desktops. Tuned for Quest 2."></meta>
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0, shrink-to-fit=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="gray-translucent" />
<!-- A-frame component libraries -->
<script src="aframePACKAGE/aframe-v1.1.0.min.js"></script>
<script src="aframePACKAGE/aframe-rounded-component.min.js"></script>
<script src="aframePACKAGE/aframe-text-geometry-component.min.js"></script>
<script src="aframePACKAGE/aframe-extras.min.js"></script>
<script src="aframePACKAGE/aframe-layout-component.min.js"></script>
<script type="text/javascript">// Set to true if you want photo labels to be spoken when onmouseenter=. User must have clicked first once to initiate audio context (unfortunate, but browsers require now)
// Does not presently work on Oculus browser, but does in Chrome desktop browser
var Speech = false;
var audio1 = new Audio('assets/wav/action.wav');
var audio2 = new Audio('assets/wav/swoosh.wav');
var not360video = false;function speakInfo(narration) {var audio_msg = new SpeechSynthesisUtterance(narration);if (Speech === true) {
function changeOrb(orb_num) {// change our orb sky
document.getElementById('orbSky').setAttribute('material', 'src: #orb' + orb_num.toString());
function sqrImg(imgNum) {// Make the 2D Popup image visible
document.getElementById('orb_2Dimg' + imgNum).setAttribute('visible', true);
// Make the location label visible
document.getElementById('OrbName_place' + imgNum).setAttribute('visible', true);
}function dspImg(dimgNum) {// Make the 2D Popup image invisible
document.getElementById('orb_2Dimg' + dimgNum).setAttribute('visible', false);
// Make the location label invisible
document.getElementById('OrbName_place' + dimgNum).setAttribute('visible', false);
}AFRAME.registerComponent('audiohandler', {
init:function() {
let playing = false;
let audio = document.querySelector("#playAudio");
this.el.addEventListener('click', () => {
if(!playing) {;
playing = true;
} else {
audio.currentTime = 0;
playing = false;
function playSwoosh() {;
function playBlip() {;
function playSound() {//alert("TEST Sound playing functional!!!");
function play360() {document.getElementById('vrVideo360').setAttribute('visible', true);
document.getElementById('orbSky').setAttribute('visible', false);
// to set specific time of video
//document.querySelector("#video-src") = 0 // start of video
// to play the videosphere
// it is a 360 video so we make sure video screen is off because using a-videosphere instead
document.getElementById('video-screen').setAttribute('visible', false);

function STOPplay360() {// The user will need to pause their own video as audio will play in background while we return to another still 360 degree Orb that was selected... music can continue to play as well if it was selecteddocument.getElementById('vrVideo360').setAttribute('visible', false);
document.getElementById('orbSky').setAttribute('visible', true);
<button id="playButton" type="button">Play Music</button>
<audio id="playAudio" autoplay loop>
<source src="assets/wav/Sky.wav" type="audio/mpeg">
<a-scene background="color: #FAFAFA" raycaster="objects: .clickable"><a-assets timeout="29000"><!-- mixin used to animate selected orbs -->
<a-mixin id="marble" geometry="primitive: sphere" scale=".45 .45 .45" animation__rotation="startEvents: mouseenter; pauseEvents: mouseleave; resumeEvents: mouseenter; property: rotation; to: 0 360 0; loop: true; dur: 10000" animation__mouseenter="startEvents: mouseenter; pauseEvents: mouseleave; resumeEvents: mouseenter; property: components.material.material.color; type: color; to: white; dur: 500; " animation__mouseleave="property: components.material.material.color; type: color; to: gray; startEvents: mouseleave; dur: 500;" shadow ></a-mixin>
<!-- Instructions for adjustment -->
<!-- Replace with eight 360 degree photo images of your choosing placed in images360 directory of Mind Palace structure -->
<!-- Eight images seem to be about the max, which will take some time to load. However if you have less images, say 4 =, best to remove orb5-orb8. -->
<!-- You will then need to remove (comment out) orbthumb5-orthumb8 and img2D5-img2D8 as well as OrbName_place5-OrbName_place8, orb_2Dimg5-orb_2Dimg8 -->
<!-- Make sure to adjust the text-geometry, speakInfo and NodeName values to your VR location specifics -->
<img crossorigin="anonymous" id="orb1" src="images360/nb1.JPG">
<img crossorigin="anonymous" id="orb2" src="images360/nb2.JPG">
<img crossorigin="anonymous" id="orb3" src="images360/nb3.JPG">
<img crossorigin="anonymous" id="orb4" src="images360/nb4.JPG">
<img crossorigin="anonymous" id="orb5" src="images360/nb6.JPG">
<img crossorigin="anonymous" id="orb6" src="images360/nb8.JPG">
<img crossorigin="anonymous" id="orb7" src="images360/nb9.JPG">
<img crossorigin="anonymous" id="orb8" src="images360/nb10.JPG">
<!-- Replace with eight thumbnail orbs corresponding to the 360 images above to be wrapped around each orb size 512 w x 256 h -->
<img crossorigin="anonymous" id="orbthumb1" src="orbthumb/nb1.JPG">
<img crossorigin="anonymous" id="orbthumb2" src="orbthumb/nb2.JPG">
<img crossorigin="anonymous" id="orbthumb3" src="orbthumb/nb3.JPG">
<img crossorigin="anonymous" id="orbthumb4" src="orbthumb/nb4.JPG">
<img crossorigin="anonymous" id="orbthumb5" src="orbthumb/nb6.JPG">
<img crossorigin="anonymous" id="orbthumb6" src="orbthumb/nb8.JPG">
<img crossorigin="anonymous" id="orbthumb7" src="orbthumb/nb9.JPG">
<img crossorigin="anonymous" id="orbthumb8" src="orbthumb/nb10.JPG">
<!-- Replace with eight 2D images corresponding to the 360 images size 341 w x 256 h -->
<img crossorigin="anonymous" id="img2D1" src="img2D/nb1.JPG">
<img crossorigin="anonymous" id="img2D2" src="img2D/nb2.JPG">
<img crossorigin="anonymous" id="img2D3" src="img2D/nb3.JPG">
<img crossorigin="anonymous" id="img2D4" src="img2D/nb4.JPG">
<img crossorigin="anonymous" id="img2D5" src="img2D/nb6.JPG">
<img crossorigin="anonymous" id="img2D6" src="img2D/nb8.JPG">
<img crossorigin="anonymous" id="img2D7" src="img2D/nb9.JPG">
<img crossorigin="anonymous" id="img2D8" src="img2D/nb10.JPG">
<!-- Replace with floor patten to cover tripod. In floorCovering bellow set Opacity = 1 to make visible -->
<img crossorigin="anonymous" id="floor" src="assets/floor/paleFloor.jpg">
<!-- Replace MP4 video with your own -->
<video crossorigin="anonymous" id="video-src" autoplay loop="true" src="video/nb.mp4"></video>

<!-- Our 3D font -->
<a-asset-item id="optimer_bold" src="assets/fonts/optimer_bold.typeface.json"></a-asset-item>
<!-- Controls for the video player -->
<img crossorigin="anonymous" src="assets/img/play2.png" id="play" >
<img crossorigin="anonymous" src="assets/img/pause.png" id="pause" >
<img crossorigin="anonymous" src="assets/img/volume-normal.png" id="volume-normal" >
<img crossorigin="anonymous" src="assets/img/volume-mute.png" id="volume-mute" >
<img crossorigin="anonymous" src="assets/img/seek-back.png" id="seek-back" >
<!-- Music toggle control -->
<img crossorigin="anonymous" id="music-image_on" src="assets/img/music.png">
</a-assets><!-- Set first screen environment to Orb1 -->
<a-sky id="orbSky" material="src: #orb1" rotation="0 -90 0" ></a-sky>
<!-- Our spherical video screen for the video -->
<a-videosphere id="vrVideo360" rotation="0 -75 0" src="#video-src" visible="false" >
<!-- Title of the Mind Palace -->
<a-entity id="NodeName" position="-2.565 -2.102 -2.84702" rotation="-39.4 0 0" text-geometry="value: Noisebridge, 272 Capp Street, San Francisco, CA; opacity: .5; size: .175; font: #optimer_bold" material="color: #F4A460"></a-entity>
<!-- Basic movement and selection, allows for movement of menu and controls if they obstruct view with WASD keys or arrows or VR controllers -->
<a-entity id="cameraRig" movement-controls="" position="0 0 2" rotation="0 0 0">
<!-- camera -->
<a-entity id="head" camera="active: true" look-controls position="0 1.6 0" ></a-entity>
<a-entity class="leftController" hand-controls="hand: left; handModelStyle: lowPoly; color: #15ACCF" visible="true"></a-entity>

<a-entity class="rightController" hand-controls="hand: right; handModelStyle: lowPoly; color: #15ACCF" laser-controls raycaster="showLine: true; far: 10; interval: 0; objects: .clickable, a-link;" line="color: lawngreen; opacity: 0.5" visible="true"></a-entity>
</a-entity><!-- This lets us play music if browser allows it, enable audio -->
<a-box id="playButton" class="clickable" position="0.80475 -0.87 -4.32442" rotation="-27.121 0 0" material="src: #music-image_on" scale="0.25 0.25 0.25" onclick="playBlip();" audiohandler shadow ></a-box>
<!-- Video Label
<a-entity id="VideoLabel" class="clickable" position="-0.40113 -0.598 -4.5" rotation="-24.697 0 0" text-geometry="value: Beach 360 Video; opacity: .5; size: 0.09; font: #optimer_bold" onclick="playBlip();speakInfo('Beach 360 Degree Video');" material="color: #F4A460"></a-entity>
<!-- Replace labels for the orbs if you want them -->
<a-entity id="OrbName_place1" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 1; size: 0.12; font: #optimer_bold" material="color: #F4A460" visible="false"></a-entity>
<a-entity id="OrbName_place2" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 2; size: 0.12; font: #optimer_bold" material="color: #F4A460" visible="false"></a-entity>
<a-entity id="OrbName_place3" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 3; size: 0.12; font: #optimer_bold" material="color: #F4A460" visible="false"></a-entity>
<a-entity id="OrbName_place4" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 4; size: 0.12; font: #optimer_bold" material="color: #F4A460" visible="false"></a-entity>
<a-entity id="OrbName_place5" material="color: #F4A460" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 5; size: 0.12; font: #optimer_bold" visible="false"></a-entity>
<a-entity id="OrbName_place6" material="color: #F4A460" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 6; size: 0.12; font: #optimer_bold" visible="false"></a-entity>
<a-entity id="OrbName_place7" material="color: #F4A460" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 7; size: 0.12; font: #optimer_bold" visible="false"></a-entity>
<a-entity id="OrbName_place8" material="color: #F4A460" position="-0.4595 1.53696 -4.5" rotation="-24.697 0 0" text-geometry="value: Noisebridge Photo 8; size: 0.12; font: #optimer_bold" visible="false"></a-entity>
<!-- Popup 2D images of "primary view" (the most dramatic view for visually descriptive purposes of 2D) on the 3D Orb -->
<a-plane id="orb_2Dimg1" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D1" visible="false"></a-plane>
<a-plane id="orb_2Dimg2" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D2" visible="false"></a-plane>
<a-plane id="orb_2Dimg3" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D3" visible="false"></a-plane>
<a-plane id="orb_2Dimg4" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D4" visible="false"></a-plane>
<a-plane id="orb_2Dimg5" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D5" visible="false"></a-plane>
<a-plane id="orb_2Dimg6" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D6" visible="false"></a-plane>
<a-plane id="orb_2Dimg7" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D7" visible="false"></a-plane>
<a-plane id="orb_2Dimg8" position="0.00557 0.479 -4.5" scale="2.168 1.8 0.1" rotation="0 0 0" material="src: #img2D8" visible="false"></a-plane>
<!-- -->
<a-plane id="floorCovering" position="0.00557 -58 0" scale="85 85 0.1" rotation="-90 0 90" material="src: #floor" visible="true" opacity="0"></a-plane>
<!-- Translucent base for orbs
<a-rounded radius="0.1" top-left-radius="0.6" top-right-radius="0.6" bottom-left-radius="0.6" bottom-right-radius="0.6" position="-2.667 -2.20165 -4.13398" scale="0.661 0.218 0.00001" rotation="-50 0 0" width="8" height="8" color="#657383" opacity=".35" shadow="" rounded=""></a-rounded>
<!-- Controls for display of video screen, seems to work nicely, javascript below </scene> tag below -->

<a-sound id="alert-sound" src="src: url(assets/wav/action.wav)" autoplay="false" position="0 0 0"></a-sound>
<a-video id="video-screen" src="#video-src" position="0.00193 1.02935 -5.4166" rotation="0 0 0" scale="0.564 0.697 1" width="8" height="4" rotation="0 0 0" visible="false"></a-video>
<a-image class="clickable" id="control-back" width="0.4" height="0.4" src="#seek-back" position="-0.426 -0.92581 -4.49178" rotation="0 0 0" visible="true" scale="0.85 0.85 0.85"></a-image>
<a-image class="clickable" id="control-play" width="0.4" height="0.4" src="#play" position="-0.015 -0.92581 -4.49178" rotation="0 0 0" onclick="play360();"></a-image><a-image class="clickable" id="control-volume" width="0.4" height="0.4" src="#volume-mute" position="0.42174 -0.92581 -4.49178" rotation="0 0 0" visible="true" scale="0.75 0.75 0.75"></a-image><!-- END CONTROLS --><!-- PROGRESSBAR -->
<a-entity id="progress-bar" geometry="primitive:plane;height:0.1;width:4" material="opacity:0;transparent:true;visible:false" position="0.03516 -0.934 -5.48963" rotation="0 0 0">
<a-plane id="progress-bar-track" width="4" height="0.1" color="gray" position="" opacity="0.2" visible="false" material="" geometry=""></a-plane>
<a-plane id="progress-bar-fill" width="3.0772968174269693" height="0.1" color="#7198e5" position="-0.4613515912865154 0 0.01438" geometry="" visible="false" material=""></a-plane>
<a-entity layout="type: circle; radius: 2.5" position="0 0 -5">

<!-- Actual orbs and there rotation, play speech discription if you want to. Set Speech to true. -->
<a-sphere id="orb_place1" class="clickable" mixin="marble" rotation="0 -125 0" material="src: #orbthumb1" onclick="playSwoosh();changeOrb(1);STOPplay360();" onmouseenter="sqrImg(1);speakInfo('Photo 1');" onmouseleave="dspImg(1);"></a-sphere>
<a-sphere id="orb_place2" class="clickable" mixin="marble" rotation="0 85 0" material="src: #orbthumb2" onclick="playSwoosh();changeOrb(2);STOPplay360();" onmouseenter="sqrImg(2);speakInfo('Photo 2');" onmouseleave="dspImg(2);"></a-sphere>
<a-sphere id="orb_place3" class="clickable" mixin="marble" rotation="0 -75 0" material="src: #orbthumb3" onclick="playSwoosh();changeOrb(3);STOPplay360();" onmouseenter="sqrImg(3);speakInfo('Photo 3');" onmouseleave="dspImg(3);"></a-sphere>
<a-sphere id="orb_place4" class="clickable" mixin="marble" rotation="0 -75 0" material="src: #orbthumb4" onclick="playSwoosh();changeOrb(4);STOPplay360();" onmouseenter="sqrImg(4);speakInfo('Photo 4');" onmouseleave="dspImg(4);"></a-sphere>
<a-sphere id="orb_place5" class="clickable" mixin="marble" rotation="0 -75 0" material="src: #orbthumb5" onclick="playSwoosh();changeOrb(5);STOPplay360();" onmouseenter="sqrImg(5);speakInfo('Photo 5');" onmouseleave="dspImg(5);"></a-sphere>
<a-sphere id="orb_place6" class="clickable" mixin="marble" rotation="0 -75 0" material="src: #orbthumb6" onclick="playSwoosh();changeOrb(6);STOPplay360();" onmouseenter="sqrImg(6);speakInfo('Photo 6');" onmouseleave="dspImg(6);"></a-sphere>
<a-sphere id="orb_place7" class="clickable" mixin="marble" rotation="0 -75 0" material="src: #orbthumb7" onclick="playSwoosh();changeOrb(7);STOPplay360();" onmouseenter="sqrImg(7);speakInfo('Photo 7');" onmouseleave="dspImg(7);"></a-sphere>
<a-sphere id="orb_place8" class="clickable" mixin="marble" rotation="0 45 0" material="src: #orbthumb8" onclick="playSwoosh();changeOrb(8);STOPplay360();" onmouseenter="sqrImg(8);speakInfo('Photo 8');" onmouseleave="dspImg(8);"></a-sphere>
</a-scene><!-- A Video Player Script (still works, when permissions enabled) -->
<script type="text/javascript">
// Is a VR Headset connected at this moment?
if (!AFRAME.utils.device.checkHeadsetConnected()) {
//No, So let them have the mouse cursor
document.getElementsByTagName("a-scene")[0].setAttribute("cursor", "rayOrigin:mouse");

//Google Code for un-audio mute
// Existing code unchanged.
window.onload = function() {
var context = new AudioContext();
}// One-liner to resume playback when user interacted with the page.
document.querySelector('button').addEventListener('click', function() {
context.resume().then(() => {
console.log('Playback resumed successfully');
var AVideoPlayer = function() {
// Vals
this.duration = 0;
this.current_progress = 0;
this.progressWidth = 4;
this.paused = true;
// Elems
this.elProgressBar = null;
this.elProgressTrack = null;
this.elProgressFill = null;
this.elAlertSound = null;
this.elVideo = null;
this.elVideoScreen = null;
this.elControlBack = null;
this.elControlPlay = null;
this.elControlVolume = null;
this._initElements = function() {
this.elProgressBar = document.getElementById('progress-bar');
this.elProgressTrack = document.getElementById('progress-bar-track');
this.elProgressFill = document.getElementById('progress-bar-fill');
this.elAlertSound = document.getElementById('alert-sound');
this.elVideo = document.getElementById('video-src');
this.elVideoScreen = document.getElementById('video-screen');
this.elControlBack = document.getElementById('control-back');
this.elControlPlay = document.getElementById('control-play');
this.elControlVolume = document.getElementById('control-volume');
this.setProgress = function(progress) {
var new_progress = this.progressWidth*progress;
var progress_coord = this._getProgressCoord();
if (progress_coord != undefined) {
progress_coord.x = -(this.progressWidth-new_progress)/2;
this._getProgressCoord = function() {
return AFRAME.utils.coordinates.parse(this.elProgressFill.getAttribute("position"))
this._getProgressWidth = function() {
return parseFloat(this.elProgressFill.getAttribute("width"));
this._setProgressCoord = function(coord) {
this.elProgressFill.setAttribute("position", coord);
this._setProgressWidth = function(width) {
this.elProgressFill.setAttribute("width", width);
this.isProgressBarVisible = function(isVisible) {
this.elProgressTrack.setAttribute("visible", isVisible);
this.elProgressFill.setAttribute("visible", isVisible);
this.isControlVisible = function(isVisible) {
this.elControlBack.setAttribute("visible", isVisible);
this.elControlVolume.setAttribute("visible", isVisible);
if (not360video) {
this.elVideoScreen.setAttribute("visible", isVisible);
this._addPlayerEvents = function() {
var that = this;
this.elVideo.onplay = function() {
that.duration = this.duration;
this.elVideo.ontimeupdate = function() {
if (that.duration > 0) {
that.current_progress = this.currentTime/that.duration;
this._addControlsEvent = function() {
var that = this;
this.elControlPlay.addEventListener('click', function () {
if (that.elVideo.paused) {
this.setAttribute('src', '#pause');;
that.paused = false;
} else {
this.setAttribute('src', '#play');
that.paused = true;
this.elControlVolume.addEventListener('click', function () {
if (that.elVideo.muted) {
that.elVideo.muted = false;
this.setAttribute('src', '#volume-normal');
} else {
that.elVideo.muted = true;
this.setAttribute('src', '#volume-mute');
this.elControlBack.addEventListener('click', function () {
that.elVideo.currentTime = 0;
this._addProgressEvent = function() {
var that = this;
this.elProgressBar.addEventListener('click', function (e) {
if (e.detail == undefined || e.detail.intersection == undefined || that.duration === 0) {
let seekedPosition = (e.detail.intersection.point.x+(that.progressWidth/2))/that.progressWidth;
try {
let seekedTime = seekedPosition*that.duration;
that.elVideo.currentTime = seekedTime;
} catch (e) {
this._playAudioAlert = function() {
if (this.elAlertSound.components !== undefined && this.elAlertSound.components.sound !== undefined) {
this._mobileFriendly = function() {
if (AFRAME.utils.device.isMobile()) {
var that = this;
let video_permission = document.getElementById('video-permission');
let video_permission_button = document.getElementById('video-permission-button'); = 'block';
video_permission_button.addEventListener("click", function() { = 'none';;
}, false);
this.init = function() {
let scene = document.querySelector('a-scene');
var cursor = document.querySelector('a-cursor');
scene.lightOff = function() {
//scene.islightOn = true;
//scene.setAttribute('animation__fog', "property: fog.color; to: #0c192a; dur: 800; easing: easeInQuad;");
scene.lightOn = function() {
//scene.islightOn = false;
//scene.setAttribute('animation__fogback', "property: fog.color; to: #dbdedb; dur: 800");
* AVideoPlayer
var videoPlayer = new AVideoPlayer();
document.querySelector('#control-play').addEventListener('click', function () {
//if (videoPlayer.paused) {
//} else {

If you would be so kind as to copy the above code into your favorite line numbered editor, such as Sublime (which is still a free download at the time of this writing), I will be able to easily comment on specific aspects of the code functionality in this article.

Yes it is a large file, but it does a lot!

Lines 14 thru 19

Lines 14 through 19 is where the big magic begins. These lines load the A-frame components necessary to accomplish the VR 360 environment.

Here are links to the different components. Please note the version numbers on A-frame, because it may not work under all future versions, especially some of the newer ones because of rapid changes to the code base and specs. I have tested to the best of my ability, and here is a GitHub link to my web app.

Lines 143 through 176

Lines 143 through 176 is where most of the paths to your 360 data is referenced. For example on line 149 we have:

<img crossorigin="anonymous" id="orb1" src="images360/nb1.JPG">

This loads a full 360 degree image taken with a 360 camera stitched into a equirectangular image format.

Example of a equirectangular image. Subject: Trex impression skeleton in science museum. Notice the image distortions.

Note the id is “orb1”. Each orb label is incremented orb2, orb3, etc. on subsequent lines in the code. This image will be displayed by wrapping it on the inside surface of a sphere which will surround the viewer in their VR headset.

New Research note . . .

Back to the article. So you will want to replace each of the orb1-orb8, (lines 149–156) with 360 equirectangular images of your taking from your 360 degree camera. Notice they are stored in the images360/ folder.

Orbthub images wrapped around small spheres to allow for teleportation to different 360 degree loacations.

Now the next step will require something like Photoshop. We are going to take each of the images we stored in images360 and copy them to antother folder call orbthumb with the same filename. Then we will reduce the image size of each of the images store in orbtumb to a 512 wide x 256 height. You can do this in Photoshop with image > image size option overwriting each file. Or any other image editor which provides for image sizing capability. This way we will use the images to wrap aroung the outside of small spheres to show different locations you can teleport to instantly by clicking on them. Also they will be reduced in size to provide for quicker loading. Yes, I know, in the future this can be done programatically to save downloading extra images. But for now, let’s do it this way.

Now make sure that lines 159–166 have the same names after the orbthumb path as the reduced images you just resized to 512 wide x 256 height.

A small 2D image to display the teleportation location. More recognizable than the orbthumb spheres. Location: My favorite book store. Yes, I still read paper novels, because I still enjoy the physicalness of a book and tuning pages with my actual hands, and fingers!

I admit the next steps in the process is mostly tedious, but I found it improves the user experience. So I recommend it.

Next we are going to add small 2D images of the teleport locations so that it is more recognizable and appear to the user when they mouse, or controller over the orbtumbs of the spheres.

Copy the images you sized in the orbthumb folder into the img2D/ folder.

Lines 169–176 for the 2D images in img2D folder. Images size 341 wide x 256 height. Same file name as others.

We are going to resize them again to 341 wide x 256 height. But we are going to overwrite them with images screenshot from the desktop.

You can save this process for last. Once the HTML is hosted on a server and you are able to access it. You will need to have it setup as https:// with a SSL certificate. Something beyond the explaining scope of this article.

Launch your webpage, then click on each orb sphere, teleporting to each location. Rotate each view to the desired direction and take a screenshot. . With windows it is with the Prt Scr button. You can move the circular menu out tof the way by using the WASD keys on the keyboard until they are out of screenshot view.

Take that captured screenshot and paste it into Photoshop and resize it to 341 wide x 256 height and save it by overwriting each of the appropriate img2D files carefully for each screenshot teleport location.

This is where our 360 degree videos are stored in the video folder.

We are almost there. Of course filming and editing a 360 degree video is no trivial task. However, if you have a 360 degree video in equirectangular format you will store the .mp4 video in the video/ folder as shown on line 182. I suggest setting loop to true. The video will begin playing in 360 when the user selects the green play button.

Now the final customizations are to lines 207 and lines 228–235. On these lines you will change text value to match the specifics of your locations and images. Adjustments to text will be made after the

text-geometry=”value: Whatever text you want to display

as in change “Whatever text you want to display”.

Something you should know and could be helpful in adjusting the positions of things. Once your HTML page is loaded you can launch the inspector in A-frame by holding down the <CTRL> + <ALT> + <i> keys.

Here is the source for my menu page which should be self explainitory:

<!DOCTYPE html>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width,shrink-to-fit=no,user-scalable=no,maximum-scale=1,minimum-scale=1">
<title>Mind Place 360 Web App</title>
<meta name="description" content="This is a index to 360 degree photo and video structures for VR headsets. Tuned for Quest 2."></meta>
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0, shrink-to-fit=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="gray-translucent" />
<body bgcolor="#24CAFF">
<h2><font color="#0E5166">Mind Place 360 Web App</font></h2>
<img src="menuImage.jpg" /><br>
<p><font color="#FFFFFF">WHAT IS THIS?</font> <font color="#0E5166">This is a launch menu to 360 degree photos and videos for use with VR headsets, desktops (Chrome browser) and phones. Tuned for the Oculus/Meta Quest 2 VR headset. Each will take a while to load because of the heavy graphics being download from the server. Please be patient for a full 360 immersive virtual reality experience.</font></p>
<p><font color="#FFFFFF">INSTRUCTION CONTROL & NAVIGATION:</font> <font color="#0E5166">Right mouse button drag in center of screen rotates 360 view (on Apple computer, use command button and mouse/pad). Selecting on Orbs changes location. Esc key usually give back cursor control on desktop. WASD or arrow keys move menu only around. VR headsets use controllers, select with right index finger trigger.</font></p>
<p><font color="#0E5166">Phones less recommended. For better experience use desktop or VR headset which is best, if available. Share the links with others.</font></p>
<h3><font color="#0E5166">Links To 360 Places To Explore</font></h3>
<a href="" target="_blank" >Mind Place 360 Web App</a><br><br>
<!-- Some examples . . . . put you links to Mind Place pages here. --><a href="" target="_blank" >Mike's VR Log 1</a><br><br>
<a href="" target="_blank" >Mike's VR Log 2</a><br><br>
<a href="" target="_blank" >Mike's VR Log 3</a><br><br>
<a href="" target="_blank" >San Francisco Beach and Downtown</a><br><br>
<a href="" target="_blank" >Noisebridge</a><br><br>
<a href="" target="_blank" >City Art</a><br><br>
<a href="" target="_blank" >Rossi Mission SF</a><br><br>

Well that’s about it for this article. If you enjoyed it, please clap me up, and share with others. You can follow me on twitter, and find out more about my other VR projects at Rocket Virtual.

Github Source


Other virtual reality articles I have written about WebXR and A-frame:



Michael McAnally

Temporary gathering of sentient stardust. Free thinker. Evolving human. Writer, coder, and artist.