janusweb
Version:
Web client for JanusVR worlds
309 lines (283 loc) • 9.96 kB
JavaScript
/* XR URI Fragments spec (https://xrfragment.org)
*
* level0 sidecar files as per the XR URI Fragments spec
* https://xrfragment.org/#sidecar%20files
*
* NOTE: you can also load sidecarfiles in a roomscript manually:
*
* elation.events.add(null, 'room_load_complete', function(e){
* if( room?.sidecarfile ) room.sidecarfile.load()
* })
*/
elation.require([], function() {
elation.extend('janusweb.sidecarfile', class {
constructor(object) {
this.scene = elation.engine.instances.default.systems.world.scene['world-3d']
this._object = object
this.extension = /\.(gltf|glb|dae)$/
this.extensionXRF = /\.xrf\./
if( room.url.match(this.extension) && this.isXRF(this.hideSystemFolder) ){
this.load()
}
}
load(){
this.cleanup()
this.loadWebVTT() // https://xrfragment.org/#sidecar%20files
this.loadSound() // https://xrfragment.org/#sidecar%20files
this.initStartButton()
this.initSubtitle()
}
hideSystemFolder(o){
// https://xrfragment.org/#system%20folders
if(o.name[0] == '_'){
o.visible = false
o.pickable = false
}
}
isXRF(cb){
// for XRF heuristics see https://xrfragment.org/#%F0%9F%93%9C%20level0%3A%20File
let heuristic = room.url.match(this.extensionXRF)
this.scene.traverse( (o) => {
if( o.userData?.href ) heuristic = true // has XRF hyperlink
if( o.name[0] == '_' ) heuristic = true // has XRF system folder
})
return heuristic
}
loadSound(){ // https://xrfragment.org/#sidecar%20files
room.removeObject('xrf_audio')
const audio = room.url.replace(this.extension,'.ogg')
room.loadNewAsset("sound", {id:"xrf_audio", src:audio,proxy:false})
this.sound = room.createObject('sound',{ id: "xrf_audio", js_id: 'xrf_audio', loop:true })
}
loadWebVTT(){ // https://xrfragment.org/#sidecar%20files
const webvtt = room.url.replace(this.extension,'.vtt')
fetch( webvtt )
.then( (res) => res.text() )
.then( (webvtt) => {
this.webvtt = this.parseWEBVTT(webvtt)
})
.catch( () => false ) // no biggy (optional)
}
initStartButton(){
this.btn = room.createObject('object',{
id: 'cube',
js_id: 'btnstart',
pos: player.localToWorld( V(0,1.8,-1) ),
scale: '0.5 0.125 0.01',
col: '0.5 0.5 0.5',
lighting:false,
sync: true,
billboard: 'y',
collision_id: 'cube'
})
const label = this.btn.createObject('text',{
text: 'Start experience',
lighting:false,
col: '0 0 0',
pos: '0 -0.15 0.5',
scale: '1.2 5 1.2',
})
this.btn.addEventListener('click', () => this.start() )
this.positionStartButton()
}
positionStartButton(){
setTimeout( () => {
this.btn.pos = player.localToWorld( V(0,1.8,-1) )
}, 500)
}
initSubtitle(){
this.subtitle = player.createObject('paragraph',{
js_id: 'subtitle',
pos: '0 1.7 -1.5',
lighting: false,
pickable: false,
css: `.paragraphcontainer{
background: transparent;
text-align:center;
height:100%;
width:100%;
display: flex;
align-items: center;
font-size:55px;
line-height:55px;
text-shadow: 0px 0px 2px #FFFF;
color:black;
}
.subtitle{
width:100%;
}
.loading{
background:#FFF;
display:inline-block;
font-size:22px;
font-family: monospace;
line-height:30px;
padding:20px;
border-radius:15px;
}
`,
text_col: '1 1 1',
back_col: '1 1 1'
})
this.subtitle.objects['3d'].depthWrite = false
this.subtitle.objects['3d'].depthTest = false
this.subtitle.objects['3d'].renderOrder = 100
this.subtitle.setHTML = (html) => {
this.subtitle.visible = true
this.subtitle.text = `<div class='subtitle'>${html}</div>`
}
}
update(){
if( this.update.id || !this.playing ) return // throttle
const advance = () => {
if( !this?.sound?.timeoffset && this?.sound?.audio?.context){
this.sound.timeoffset = this.sound.audio.context.currentTime
}
if( this?.sound?.timeoffset != undefined &&
this.webvtt?.items[ this.webvtt?.i ] &&
this?.sound?.playStarted &&
this?.sound?.audio?.context?.currentTime ){
const time = this.sound.audio.context.currentTime - this.sound.timeoffset;
let item = this.webvtt.items[ this.webvtt.i ]
if( !item.seen && time > item.start.ts ){
this.subtitle.setHTML(`
${ item.who ? `<b>${item.who}:</b><br/>` : '' }
${item.text.replace(/\n/g,'<br/>')}
`)
if( room.hyperlink && item.href && !item.seen ){
room.hyperlink.execute(item.href, {portalActivateDelay:6000})
}
item.seen = true
}
if( item.seen && time > item.stop.ts ){
this.subtitle.visible = false
this.webvtt.i++
}
}
this.update.id = false
}
this.update.id = setTimeout( () => advance(), 100 ) // throttle
}
// naive webvtt parser
parseWEBVTT(text) {
let i = 0;
const WEBVTT_HEADER = /^WEBVTT/i;
const TIME_LINE = /^([0-9:.]+)\s+-->\s+([0-9:.]+)(?:\s+(.+))?$/;
const WHO = /^<v ([^>]+)>/
const HREF = / href:([^ ]+)/
const result = { type: "WEBVTT", items: [], i: 0 };
const lines = text.split(/\r?\n/)
.map( (line) => line.trim() )
const vttToSeconds = (vttString) => {
const parts = vttString.split(':');
let hours = 0, minutes = 0, secondsWithMs;
if (parts.length === 3) { [hours, minutes, secondsWithMs] = parts; }
else { [minutes, secondsWithMs] = parts; } // Format is MM:SS.mmm
const [seconds, milliseconds] = secondsWithMs.split('.');
return (
(parseInt(hours) * 3600) +
(parseInt(minutes) * 60) +
(parseInt(seconds) * 1) +
parseInt(milliseconds || 0)
);
}
// Skip the WEBVTT header line
if (WEBVTT_HEADER.test(lines[i].trim())) i++
while (i < lines.length) {
if ( lines[i] === "") { i++; continue; } // Skip empty lines
// Match cue timing line
const timeMatch = lines[i].match(TIME_LINE);
if (timeMatch) {
const item = {
start: {str:timeMatch[1], ts: vttToSeconds(timeMatch[1]) },
stop: {str:timeMatch[2], ts: vttToSeconds(timeMatch[2]) },
href: lines[i].match(HREF) ? lines[i].match(HREF)[1] : false,
who: false,
text: ""
};
i++;
const speakerMatch = lines[i]?.match(WHO);
if (speakerMatch) {
item.who = speakerMatch[1];
lines[i] = lines[i].replace(WHO,'');
}
// Collect all text lines until next empty line
while (i < lines.length && lines[i].trim() !== "") {
item.text += (item.text ? "\n" : "") + lines[i] ;
i++;
}
result.items.push(item);
} else { i++; }
}
return result;
}
start(){
// reset subtitles
if( this?.webvtt?.items ){
this.webvtt.items.map( (item) => item.seen = undefined )
this.webvtt.i = 0;
}
// start sound
this.sound.pos = '0 0 0'
if( this.sound.isPlaying ) this.sound.stop()
this.sound.timeoffset = undefined
this.sound.play()
// ensure subtitle
this.subtitle.visible = false
player.add(this.subtitle)
// hide button
this.btn.visible = false
this.btn.pickable = false
// mark playing
this.playing = true
}
stop(){
this.playing = false
this.update.id = false
this.subtitle.visible = false
this.subtitle.text = ''
this.btn.visible = true
this.btn.pickable = true
}
cleanup(){
const deleteJsId = (js_id) => {
for( let i in player.children )
if( player.children[i].js_id == js_id )
player.remove( player.children[i] )
}
deleteJsId('subtitle')
}
})
});
xrf_install_sidecarfiles = function(){
if( !room.objects?.scene?.modelasset?.loaded ) {
return setTimeout( xrf_install_sidecarfiles, 300 )
}
if( !room.sidecarfile ) room.sidecarfile = new elation.janusweb.sidecarfile(room);
else{
room.sidecarfile.positionStartButton() // wait for user being spawned
room.sidecarfile.stop()
}
}
xrf_install_sidecarfiles()
elation.events.add(null, 'room_load_complete', xrf_install_sidecarfiles )
elation.events.add(null, 'room_enable', xrf_install_sidecarfiles )
elation.events.add(null, 'janusweb_script_frame', function(){
if( room?.sidecarfile ) room.sidecarfile.update()
})
elation.events.add(null, 'room_load_start', function(e){
if( !e.data ) return
if( room?.sidecarfile?.subtitle ) room.sidecarfile.subtitle.setHTML(`<div class='loading'>🔗 ${e.data.name}<br/><br/>please wait..</div>`)
})
elation.events.add(null, 'room_disable', function(){
if( room?.sidecarfile?.subtitle ) room.sidecarfile.stop()
})
// some convenience WebVTT cue settings => room function mappings
// href:#fadeAudioOut&spawnhere => room.fadeAudioOut()
// href:#myfunc=3 => room.myfunc(3)
elation.events.add(null, 'href', function(e){
if( room?.hyperlink ){
const {url,hash} = room.hyperlink.getUrlObject(e.data.href)
hash.forEach( (v,k) => { if( room[k] ) room[k](v) })
}
})