diff --git a/.env.defaults b/.env.defaults index c54467364023fe9ba11b643605873791013b2a5c..059fe70a2e8f383e7122e640f41ce4fc31dff595 100644 --- a/.env.defaults +++ b/.env.defaults @@ -17,5 +17,5 @@ BASE_ASSETS_PATH=/ # This origin trial token is used to enable WebVR in Android Chrome for hubs.mozilla.com. # You can find more information about getting your own origin trial token here: # https://github.com/GoogleChrome/OriginTrials/blob/gh-pages/developer-guide.md -ORIGIN_TRIAL_TOKEN="AmTuFlYFGJ4KEbPVE20U0qoWZI3NZuaO8bjjcQvQI4OvDVC4Iyun5gkD8lwtNbrEzh617m5nig0+8QC+Pz6powYAAABVeyJvcmlnaW4iOiJodHRwczovL2h1YnMubW96aWxsYS5jb206NDQzIiwiZmVhdHVyZSI6IldlYlZSMS4xTTYyIiwiZXhwaXJ5IjoxNTM0ODg3ODE1fQ==" -ORIGIN_TRIAL_EXPIRES="2018-08-21" +ORIGIN_TRIAL_TOKEN="AtOSmCIwReA1cq9L756ii6hccpiM4ObrwF0bYDmr1nNzMQi2zTjoN1puufPHt+QUwcx0F6rbLEPj/YrQanRQUA8AAABVeyJvcmlnaW4iOiJodHRwczovL2h1YnMubW96aWxsYS5jb206NDQzIiwiZmVhdHVyZSI6IldlYlZSMS4xTTYyIiwiZXhwaXJ5IjoxNTM2NjI0MDAwfQ==" +ORIGIN_TRIAL_EXPIRES="2018-09-11" diff --git a/README.md b/README.md index 790462087e2a87d08b71a39403afabe98de81e3e..503057f930fe9fc7cfe37c01a18990860274f5a7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ and then upload the files in the `dist` folder to your hosting provider. If you are running your own servers, you can modify the environment variables `JANUS_SERVER` and `RETICULUM_SERVER` when building to point Hubs to your own infrastructure. -See `scripts/default.env` for the full set of environment variables that can modify +See `.env.defaults` for the full set of environment variables that can modify Hubs' behavior at build time. ## hubs.local Host Entry diff --git a/src/components/image-plus.js b/src/components/image-plus.js deleted file mode 100644 index 69f4440ac7484f44ec69851e8eb341ad07dda321..0000000000000000000000000000000000000000 --- a/src/components/image-plus.js +++ /dev/null @@ -1,265 +0,0 @@ -import GIFWorker from "../workers/gifparsing.worker.js"; -import errorImageSrc from "!!url-loader!../assets/images/media-error.gif"; - -class GIFTexture extends THREE.Texture { - constructor(frames, delays, disposals) { - super(document.createElement("canvas")); - this.image.width = frames[0].width; - this.image.height = frames[0].height; - - this._ctx = this.image.getContext("2d"); - - this.generateMipmaps = false; - this.isVideoTexture = true; - this.minFilter = THREE.NearestFilter; - - this.frames = frames; - this.delays = delays; - this.disposals = disposals; - - this.frame = 0; - this.frameStartTime = Date.now(); - } - - update() { - if (!this.frames || !this.delays || !this.disposals) return; - const now = Date.now(); - if (now - this.frameStartTime > this.delays[this.frame]) { - if (this.disposals[this.frame] === 2) { - this._ctx.clearRect(0, 0, this.image.width, this.image.width); - } - this.frame = (this.frame + 1) % this.frames.length; - this.frameStartTime = now; - this._ctx.drawImage(this.frames[this.frame], 0, 0, this.image.width, this.image.height); - this.needsUpdate = true; - } - } -} - -async function createGIFTexture(url) { - return new Promise((resolve, reject) => { - // TODO: pool workers - const worker = new GIFWorker(); - worker.onmessage = e => { - const [success, frames, delays, disposals] = e.data; - if (!success) { - reject(`error loading gif: ${e.data[1]}`); - return; - } - - let loadCnt = 0; - for (let i = 0; i < frames.length; i++) { - const img = new Image(); - img.onload = e => { - loadCnt++; - frames[i] = e.target; - if (loadCnt === frames.length) { - const texture = new GIFTexture(frames, delays, disposals); - texture.image.src = url; - resolve(texture); - } - }; - img.src = frames[i]; - } - }; - fetch(url, { mode: "cors" }) - .then(r => r.arrayBuffer()) - .then(rawImageData => { - worker.postMessage(rawImageData, [rawImageData]); - }) - .catch(reject); - }); -} - -/** - * Create video element to be used as a texture. - * - * @param {string} src - Url to a video file. - * @returns {Element} Video element. - */ -function createVideoEl(src) { - const videoEl = document.createElement("video"); - videoEl.setAttribute("playsinline", ""); - videoEl.setAttribute("webkit-playsinline", ""); - videoEl.autoplay = true; - videoEl.loop = true; - videoEl.crossOrigin = "anonymous"; - videoEl.src = src; - return videoEl; -} - -function createVideoTexture(url) { - return new Promise((resolve, reject) => { - const videoEl = createVideoEl(url); - - const texture = new THREE.VideoTexture(videoEl); - texture.minFilter = THREE.LinearFilter; - videoEl.addEventListener("loadedmetadata", () => resolve(texture), { once: true }); - videoEl.onerror = reject; - - // If iOS and video is HLS, do some hacks. - if ( - AFRAME.utils.device.isIOS() && - AFRAME.utils.material.isHLS( - videoEl.src || videoEl.getAttribute("src"), - videoEl.type || videoEl.getAttribute("type") - ) - ) { - // Actually BGRA. Tell shader to correct later. - texture.format = THREE.RGBAFormat; - texture.needsCorrectionBGRA = true; - // Apparently needed for HLS. Tell shader to correct later. - texture.flipY = false; - texture.needsCorrectionFlipY = true; - } - }); -} - -const textureLoader = new THREE.TextureLoader(); -textureLoader.setCrossOrigin("anonymous"); -function createImageTexture(url) { - return new Promise((resolve, reject) => { - textureLoader.load(url, resolve, null, function(xhr) { - reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`); - }); - }); -} - -const textureCache = new Map(); - -const errorImage = new Image(); -errorImage.src = errorImageSrc; -const errorTexture = new THREE.Texture(errorImage); -errorTexture.magFilter = THREE.NearestFilter; -errorImage.onload = () => { - errorTexture.needsUpdate = true; -}; - -AFRAME.registerComponent("image-plus", { - schema: { - src: { type: "string" }, - contentType: { type: "string" }, - - depth: { default: 0.05 } - }, - - releaseTexture(src) { - if (this.mesh && this.mesh.material.map !== errorTexture) { - this.mesh.material.map = null; - this.mesh.material.needsUpdate = true; - } - - if (!textureCache.has(src)) return; - - const cacheItem = textureCache.get(src); - cacheItem.count--; - if (cacheItem.count <= 0) { - // Unload the video element to prevent it from continuing to play in the background - if (cacheItem.texture.image instanceof HTMLVideoElement) { - const video = cacheItem.texture.image; - video.pause(); - video.src = ""; - video.load(); - } - - cacheItem.texture.dispose(); - - textureCache.delete(src); - } - }, - - remove() { - this.releaseTexture(this.data.src); - }, - - async update(oldData) { - let texture; - try { - const { src, contentType } = this.data; - if (!src) return; - - if (this.mesh) { - this.releaseTexture(oldData.src); - } - - let cacheItem; - if (textureCache.has(src)) { - cacheItem = textureCache.get(src); - texture = cacheItem.texture; - cacheItem.count++; - } else { - cacheItem = { count: 1 }; - if (src === "error") { - texture = errorTexture; - } else if (contentType.includes("image/gif")) { - texture = await createGIFTexture(src); - } else if (contentType.startsWith("image/")) { - texture = await createImageTexture(src); - } else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) { - texture = await createVideoTexture(src); - cacheItem.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image); - } else { - throw new Error(`Unknown content type: ${contentType}`); - } - - texture.encoding = THREE.sRGBEncoding; - texture.minFilter = THREE.LinearFilter; - - cacheItem.texture = texture; - textureCache.set(src, cacheItem); - - // No way to cancel promises, so if src has changed while we were creating the texture just throw it away. - if (this.data.src !== src) { - this.releaseTexture(src); - return; - } - } - - if (cacheItem.audioSource) { - const sound = new THREE.PositionalAudio(this.el.sceneEl.audioListener); - sound.setNodeSource(cacheItem.audioSource); - this.el.setObject3D("sound", sound); - } - } catch (e) { - console.error("Error loading media", this.data.src, e); - texture = errorTexture; - } - - const ratio = - (texture.image.videoHeight || texture.image.height || 1.0) / - (texture.image.videoWidth || texture.image.width || 1.0); - const width = Math.min(1.0, 1.0 / ratio); - const height = Math.min(1.0, ratio); - - if (!this.mesh) { - const material = new THREE.MeshBasicMaterial(); - material.side = THREE.DoubleSide; - material.transparent = true; - material.map = texture; - material.needsUpdate = true; - - const geometry = new THREE.PlaneGeometry(); - this.mesh = new THREE.Mesh(geometry, material); - } else { - const { material } = this.mesh; - material.map = texture; - material.needsUpdate = true; - this.mesh.needsUpdate = true; - } - - this.el.setObject3D("mesh", this.mesh); - - this.mesh.scale.set(width, height, 1); - this.el.setAttribute("shape", { - shape: "box", - halfExtents: { x: width / 2, y: height / 2, z: this.data.depth } - }); - - // TODO: verify if we actually need to do this - if (this.el.components.body && this.el.components.body.body) { - this.el.components.body.syncToPhysics(); - this.el.components.body.updateCannonScale(); - } - this.el.emit("image-loaded"); - } -}); diff --git a/src/components/media-loader.js b/src/components/media-loader.js index 34d281bba37ad32eb050b90dec6ed5073c5aaf19..26b65584ed87aaa6da18195a7e01ba3652239d56 100644 --- a/src/components/media-loader.js +++ b/src/components/media-loader.js @@ -11,6 +11,7 @@ AFRAME.registerComponent("media-loader", { init() { this.onError = this.onError.bind(this); this.showLoader = this.showLoader.bind(this); + this.clearLoadingTimeout = this.clearLoadingTimeout.bind(this); }, setShapeAndScale(resize) { @@ -40,7 +41,8 @@ AFRAME.registerComponent("media-loader", { onError() { this.el.removeAttribute("gltf-model-plus"); this.el.removeAttribute("media-pager"); - this.el.setAttribute("image-plus", { src: "error" }); + this.el.removeAttribute("media-video"); + this.el.setAttribute("media-image", { src: "error" }); clearTimeout(this.showLoaderTimeout); delete this.showLoaderTimeout; }, @@ -52,6 +54,11 @@ AFRAME.registerComponent("media-loader", { delete this.showLoaderTimeout; }, + clearLoadingTimeout() { + clearTimeout(this.showLoaderTimeout); + delete this.showLoaderTimeout; + }, + async update(oldData) { try { const { src, index } = this.data; @@ -62,21 +69,27 @@ AFRAME.registerComponent("media-loader", { if (!src) return; - const { raw, images, contentType } = await resolveMedia(src, false, index); + const { raw, origin, images, contentType } = await resolveMedia(src, false, index); + + // We don't want to emit media_resolved for index updates. + if (src !== oldData.src) { + this.el.emit("media_resolved", { src, raw, origin, contentType }); + } const isPDF = contentType.startsWith("application/pdf"); - if ( - contentType.startsWith("image/") || - contentType.startsWith("video/") || - contentType.startsWith("audio/") || - isPDF - ) { + if (contentType.startsWith("video/") || contentType.startsWith("audio/")) { + this.el.removeAttribute("gltf-model-plus"); + this.el.removeAttribute("media-image"); + this.el.addEventListener("video-loaded", this.clearLoadingTimeout, { once: true }); + this.el.setAttribute("media-video", { src: raw }); + this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] }); + } else if (contentType.startsWith("image/") || isPDF) { this.el.removeAttribute("gltf-model-plus"); + this.el.removeAttribute("media-video"); this.el.addEventListener( "image-loaded", async () => { - clearTimeout(this.showLoaderTimeout); - delete this.showLoaderTimeout; + this.clearLoadingTimeout(); if (isPDF) { const maxIndex = await fetchMaxContentIndex(src, images.png); this.el.setAttribute("media-pager", { index, maxIndex }); @@ -91,7 +104,7 @@ AFRAME.registerComponent("media-loader", { this.el.removeAttribute("media-pager"); } - this.el.setAttribute("image-plus", { src: imageSrc, contentType: imageContentType }); + this.el.setAttribute("media-image", { src: imageSrc, contentType: imageContentType }); this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] }); } else if ( contentType.includes("application/octet-stream") || @@ -100,13 +113,13 @@ AFRAME.registerComponent("media-loader", { src.endsWith(".gltf") || src.endsWith(".glb") ) { - this.el.removeAttribute("image-plus"); + this.el.removeAttribute("media-image"); + this.el.removeAttribute("media-video"); this.el.removeAttribute("media-pager"); this.el.addEventListener( "model-loaded", () => { - clearTimeout(this.showLoaderTimeout); - delete this.showLoaderTimeout; + this.clearLoadingTimeout(); this.setShapeAndScale(this.data.resize); }, { once: true } diff --git a/src/components/media-views.js b/src/components/media-views.js new file mode 100644 index 0000000000000000000000000000000000000000..a408e8ca646208636156c380cceeedde80e90a59 --- /dev/null +++ b/src/components/media-views.js @@ -0,0 +1,414 @@ +import GIFWorker from "../workers/gifparsing.worker.js"; +import errorImageSrc from "!!url-loader!../assets/images/media-error.gif"; + +class GIFTexture extends THREE.Texture { + constructor(frames, delays, disposals) { + super(document.createElement("canvas")); + this.image.width = frames[0].width; + this.image.height = frames[0].height; + + this._ctx = this.image.getContext("2d"); + + this.generateMipmaps = false; + this.isVideoTexture = true; + this.minFilter = THREE.NearestFilter; + + this.frames = frames; + this.delays = delays; + this.disposals = disposals; + + this.frame = 0; + this.frameStartTime = Date.now(); + } + + update() { + if (!this.frames || !this.delays || !this.disposals) return; + const now = Date.now(); + if (now - this.frameStartTime > this.delays[this.frame]) { + if (this.disposals[this.frame] === 2) { + this._ctx.clearRect(0, 0, this.image.width, this.image.width); + } + this.frame = (this.frame + 1) % this.frames.length; + this.frameStartTime = now; + this._ctx.drawImage(this.frames[this.frame], 0, 0, this.image.width, this.image.height); + this.needsUpdate = true; + } + } +} + +async function createGIFTexture(url) { + return new Promise((resolve, reject) => { + // TODO: pool workers + const worker = new GIFWorker(); + worker.onmessage = e => { + const [success, frames, delays, disposals] = e.data; + if (!success) { + reject(`error loading gif: ${e.data[1]}`); + return; + } + + let loadCnt = 0; + for (let i = 0; i < frames.length; i++) { + const img = new Image(); + img.onload = e => { + loadCnt++; + frames[i] = e.target; + if (loadCnt === frames.length) { + const texture = new GIFTexture(frames, delays, disposals); + texture.image.src = url; + texture.encoding = THREE.sRGBEncoding; + texture.minFilter = THREE.LinearFilter; + resolve(texture); + } + }; + img.src = frames[i]; + } + }; + fetch(url, { mode: "cors" }) + .then(r => r.arrayBuffer()) + .then(rawImageData => { + worker.postMessage(rawImageData, [rawImageData]); + }) + .catch(reject); + }); +} + +/** + * Create video element to be used as a texture. + * + * @param {string} src - Url to a video file. + * @returns {Element} Video element. + */ +function createVideoEl(src) { + const videoEl = document.createElement("video"); + videoEl.setAttribute("playsinline", ""); + videoEl.setAttribute("webkit-playsinline", ""); + videoEl.loop = true; + videoEl.crossOrigin = "anonymous"; + videoEl.src = src; + return videoEl; +} + +function createVideoTexture(url) { + return new Promise((resolve, reject) => { + const videoEl = createVideoEl(url); + + const texture = new THREE.VideoTexture(videoEl); + texture.minFilter = THREE.LinearFilter; + texture.encoding = THREE.sRGBEncoding; + + videoEl.addEventListener("loadedmetadata", () => resolve(texture), { once: true }); + videoEl.onerror = reject; + + // If iOS and video is HLS, do some hacks. + if ( + AFRAME.utils.device.isIOS() && + AFRAME.utils.material.isHLS( + videoEl.src || videoEl.getAttribute("src"), + videoEl.type || videoEl.getAttribute("type") + ) + ) { + // Actually BGRA. Tell shader to correct later. + texture.format = THREE.RGBAFormat; + texture.needsCorrectionBGRA = true; + // Apparently needed for HLS. Tell shader to correct later. + texture.flipY = false; + texture.needsCorrectionFlipY = true; + } + }); +} + +function createPlaneMesh(texture) { + const material = new THREE.MeshBasicMaterial(); + material.side = THREE.DoubleSide; + material.transparent = true; + material.map = texture; + material.needsUpdate = true; + + const geometry = new THREE.PlaneGeometry(); + return new THREE.Mesh(geometry, material); +} + +function fitToTexture(el, texture) { + const ratio = + (texture.image.videoHeight || texture.image.height || 1.0) / + (texture.image.videoWidth || texture.image.width || 1.0); + const width = Math.min(1.0, 1.0 / ratio); + const height = Math.min(1.0, ratio); + el.object3DMap.mesh.scale.set(width, height, 1); + el.setAttribute("shape", { + shape: "box", + halfExtents: { x: width / 2, y: height / 2, z: 0.05 } + }); +} + +const textureLoader = new THREE.TextureLoader(); +textureLoader.setCrossOrigin("anonymous"); +function createImageTexture(url) { + return new Promise((resolve, reject) => { + textureLoader.load( + url, + texture => { + texture.encoding = THREE.sRGBEncoding; + texture.minFilter = THREE.LinearFilter; + resolve(texture); + }, + null, + function(xhr) { + reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`); + } + ); + }); +} + +class TextureCache { + cache = new Map(); + + set(src, texture) { + this.cache.set(src, { + texture, + count: 0 + }); + this.retain(src); + } + + has(src) { + return this.cache.has(src); + } + + get(src) { + return this.cache.get(src).texture; + } + + retain(src) { + const cacheItem = this.cache.get(src); + cacheItem.count++; + // console.log("retain", src, cacheItem.count); + return cacheItem.texture; + } + + release(src) { + const cacheItem = this.cache.get(src); + cacheItem.count--; + // console.log("release", src, cacheItem.count); + if (cacheItem.count <= 0) { + // Unload the video element to prevent it from continuing to play in the background + if (cacheItem.texture.image instanceof HTMLVideoElement) { + const video = cacheItem.texture.image; + video.pause(); + video.src = ""; + video.load(); + } + cacheItem.texture.dispose(); + this.cache.delete(src); + } + } +} + +const textureCache = new TextureCache(); + +const errorImage = new Image(); +errorImage.src = errorImageSrc; +const errorTexture = new THREE.Texture(errorImage); +errorTexture.magFilter = THREE.NearestFilter; +errorImage.onload = () => { + errorTexture.needsUpdate = true; +}; + +AFRAME.registerComponent("media-video", { + schema: { + src: { type: "string" }, + time: { type: "number" }, + videoPaused: { type: "boolean" }, + tickRate: { default: 1000 }, // ms interval to send time interval updates + syncTolerance: { default: 2 } + }, + + init() { + this.onPauseStateChange = this.onPauseStateChange.bind(this); + this.togglePlayingIfOwner = this.togglePlayingIfOwner.bind(this); + + this.lastUpdate = 0; + + NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { + this.networkedEl = networkedEl; + this.updatePlaybackState(); + }); + }, + + // aframe component play, unrelated to video + play() { + this.el.addEventListener("click", this.togglePlayingIfOwner); + }, + + // aframe component pause, unrelated to video + pause() { + this.el.removeEventListener("click", this.togglePlayingIfOwner); + }, + + togglePlayingIfOwner() { + if (this.networkedEl && NAF.utils.isMine(this.networkedEl) && this.video) { + this.data.videoPaused ? this.video.play() : this.video.pause(); + } + }, + + remove() { + if (this.data.src) { + textureCache.release(this.data.src); + } + }, + + onPauseStateChange() { + this.el.setAttribute("media-video", "videoPaused", this.video.paused); + }, + + async updateTexture(src) { + let texture; + try { + if (textureCache.has(src)) { + texture = textureCache.retain(src); + } else { + texture = await createVideoTexture(src); + texture.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image); + this.video = texture.image; + + this.video.addEventListener("pause", this.onPauseStateChange); + this.video.addEventListener("play", this.onPauseStateChange); + + textureCache.set(src, texture); + + // No way to cancel promises, so if src has changed while we were creating the texture just throw it away. + if (this.data.src !== src) { + textureCache.release(src); + return; + } + } + + const sound = new THREE.PositionalAudio(this.el.sceneEl.audioListener); + sound.setNodeSource(texture.audioSource); + this.el.setObject3D("sound", sound); + } catch (e) { + console.error("Error loading video", this.data.src, e); + texture = errorTexture; + } + + if (!this.mesh) { + this.mesh = createPlaneMesh(texture); + this.el.setObject3D("mesh", this.mesh); + } else { + const { material } = this.mesh; + material.map = texture; + material.needsUpdate = true; + this.mesh.needsUpdate = true; + } + + fitToTexture(this.el, texture); + + this.updatePlaybackState(true); + + this.el.emit("video-loaded"); + }, + + updatePlaybackState(force) { + if (force || (this.networkedEl && !NAF.utils.isMine(this.networkedEl) && this.video)) { + if (Math.abs(this.data.time - this.video.currentTime) > this.data.syncTolerance) { + this.video.currentTime = this.data.time; + } + this.data.videoPaused ? this.video.pause() : this.video.play(); + } + }, + + update(oldData) { + const { src } = this.data; + + this.updatePlaybackState(); + + if (!src || src === oldData.src) return; + + if (this.mesh && this.mesh.map) { + this.mesh.material.map = null; + this.mesh.material.needsUpdate = true; + if (this.mesh.map !== errorTexture) { + textureCache.release(oldData.src); + } + } + + this.updateTexture(src); + }, + + tick() { + if (this.data.videoPaused || !this.video || !this.networkedEl || !NAF.utils.isMine(this.networkedEl)) return; + + const now = performance.now(); + if (now - this.lastUpdate > this.data.tickRate) { + this.el.setAttribute("media-video", "time", this.video.currentTime); + this.lastUpdate = now; + } + } +}); + +AFRAME.registerComponent("media-image", { + schema: { + src: { type: "string" }, + contentType: { type: "string" } + }, + + remove() { + textureCache.release(this.data.src); + }, + + async update(oldData) { + let texture; + try { + const { src, contentType } = this.data; + if (!src) return; + + if (this.mesh && this.mesh.map) { + this.mesh.material.map = null; + this.mesh.material.needsUpdate = true; + if (this.mesh.map !== errorTexture) { + textureCache.release(oldData.src); + } + } + + if (textureCache.has(src)) { + texture = textureCache.retain(src); + } else { + if (src === "error") { + texture = errorTexture; + } else if (contentType.includes("image/gif")) { + texture = await createGIFTexture(src); + } else if (contentType.startsWith("image/")) { + texture = await createImageTexture(src); + } else { + throw new Error(`Unknown image content type: ${contentType}`); + } + + textureCache.set(src, texture); + + // No way to cancel promises, so if src has changed while we were creating the texture just throw it away. + if (this.data.src !== src) { + textureCache.release(src); + return; + } + } + } catch (e) { + console.error("Error loading image", this.data.src, e); + texture = errorTexture; + } + + if (!this.mesh) { + this.mesh = createPlaneMesh(texture); + this.el.setObject3D("mesh", this.mesh); + } else { + const { material } = this.mesh; + material.map = texture; + material.needsUpdate = true; + this.mesh.needsUpdate = true; + } + + fitToTexture(this.el, texture); + + this.el.emit("image-loaded"); + } +}); diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js index 60d346f584692b11d4867593fefa6d3b2f2ed2ff..599a396495bfa785984f4c017c108ee806a4e4d5 100644 --- a/src/components/super-spawner.js +++ b/src/components/super-spawner.js @@ -1,5 +1,6 @@ import { addMedia } from "../utils/media-utils"; import { waitForEvent } from "../utils/async-utils"; +import { ObjectContentOrigins } from "../object-types"; let nextGrabId = 0; /** @@ -94,7 +95,8 @@ AFRAME.registerComponent("super-spawner", { const thisGrabId = nextGrabId++; this.heldEntities.set(hand, thisGrabId); - const entity = addMedia(this.data.src, this.data.template); + const entity = addMedia(this.data.src, this.data.template, ObjectContentOrigins.SPAWNER); + entity.object3D.position.copy( this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.object3D.position ); diff --git a/src/hub.js b/src/hub.js index 118dba903d6d764b02d54df37776f1af6fe26c84..a22c0968023ea84fe253a192abd66e57d6739d5a 100644 --- a/src/hub.js +++ b/src/hub.js @@ -25,6 +25,8 @@ import joystick_dpad4 from "./behaviours/joystick-dpad4"; import msft_mr_axis_with_deadzone from "./behaviours/msft-mr-axis-with-deadzone"; import { PressedMove } from "./activators/pressedmove"; import { ReverseY } from "./activators/reversey"; +import { ObjectContentOrigins } from "./object-types"; + import "./activators/shortpress"; import "./components/wasd-to-analog2d"; //Might be a behaviour or activator in the future @@ -63,7 +65,7 @@ import "./components/networked-avatar"; import "./components/css-class"; import "./components/scene-shadow"; import "./components/avatar-replay"; -import "./components/image-plus"; +import "./components/media-views"; import "./components/pinch-to-move"; import "./components/look-on-mobile"; import "./components/pitch-yaw-rotator"; @@ -310,8 +312,9 @@ const onReady = async () => { const offset = { x: 0, y: 0, z: -1.5 }; - const spawnMediaInfrontOfPlayer = src => { - const entity = addMedia(src, "#interactable-media", true); + const spawnMediaInfrontOfPlayer = (src, contentOrigin) => { + const entity = addMedia(src, "#interactable-media", contentOrigin, true); + entity.setAttribute("offset-relative-to", { target: "#player-camera", offset @@ -319,7 +322,15 @@ const onReady = async () => { }; scene.addEventListener("add_media", e => { - spawnMediaInfrontOfPlayer(e.detail); + const contentOrigin = e.detail instanceof File ? ObjectContentOrigins.FILE : ObjectContentOrigins.URL; + + spawnMediaInfrontOfPlayer(e.detail, contentOrigin); + }); + + scene.addEventListener("object_spawned", e => { + if (hubChannel) { + hubChannel.sendObjectSpawnedEvent(e.detail.objectType); + } }); document.addEventListener("paste", e => { @@ -328,10 +339,10 @@ const onReady = async () => { const url = e.clipboardData.getData("text"); const files = e.clipboardData.files && e.clipboardData.files; if (url) { - spawnMediaInfrontOfPlayer(url); + spawnMediaInfrontOfPlayer(url, ObjectContentOrigins.URL); } else { for (const file of files) { - spawnMediaInfrontOfPlayer(file); + spawnMediaInfrontOfPlayer(file, ObjectContentOrigins.CLIPBOARD); } } }); @@ -345,10 +356,10 @@ const onReady = async () => { const url = e.dataTransfer.getData("url"); const files = e.dataTransfer.files; if (url) { - spawnMediaInfrontOfPlayer(url); + spawnMediaInfrontOfPlayer(url, ObjectContentOrigins.URL); } else { for (const file of files) { - spawnMediaInfrontOfPlayer(file); + spawnMediaInfrontOfPlayer(file, ObjectContentOrigins.FILE); } } }); diff --git a/src/network-schemas.js b/src/network-schemas.js index e515203ef69902e45b196a33f988549c4863a1c4..0bdd4d8773247ca7e2aec209f545d5928aa0b447 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -111,7 +111,15 @@ function registerNetworkSchemas() { requiresNetworkUpdate: vectorRequiresUpdate(0.5) }, "scale", - "media-loader" + "media-loader", + { + component: "media-video", + property: "time" + }, + { + component: "media-video", + property: "videoPaused" + } ] }); diff --git a/src/object-types.js b/src/object-types.js new file mode 100644 index 0000000000000000000000000000000000000000..d8f959c2531a59fe713c85b2141a013eed785be5 --- /dev/null +++ b/src/object-types.js @@ -0,0 +1,86 @@ +// Enumeration of spawned object content origins. URL means the content is +// fetched from a URL, FILE means it was an uploaded/dropped file, and +// CLIPBOARD means it was raw data pasted in from the clipboard, SPAWNER means +// it was copied over from a spawner. +export const ObjectContentOrigins = { + URL: 1, + FILE: 2, + CLIPBOARD: 3, + SPAWNER: 4 +}; + +// Enumeration of spawnable object types, used for telemetry, which encapsulates +// both the origin of the content for the object and also the type of content +// contained in the object. +const ObjectTypes = { + URL_IMAGE: 0, + URL_VIDEO: 1, + URL_MODEL: 2, + URL_PDF: 3, + URL_AUDIO: 4, + //URL_TEXT: 5, + //URL_TELEPORTER: 6, + FILE_IMAGE: 8, + FILE_VIDEO: 9, + FILE_MODEL: 10, + FILE_PDF: 11, + FILE_AUDIO: 12, + //FILE_TEXT: 13, + CLIPBOARD_IMAGE: 16, + CLIPBOARD_VIDEO: 17, + CLIPBOARD_MODEL: 18, + CLIPBOARD_PDF: 19, + CLIPBOARD_AUDIO: 20, + //CLIPBOARD_TEXT: 21, + SPAWNER_IMAGE: 24, + SPAWNER_VIDEO: 25, + SPAWNER_MODEL: 26, + SPAWNER_PDF: 27, + SPAWNER_AUDIO: 28, + //SPAWNER_TEXT: 29, + //DRAWING: 30, + UNKNOWN: 31 +}; + +// Given an origin and three object type values for URL, FILE, and CLIPBOARD +// origins respectively, return the appropriate one +function objectTypeForOrigin(contentOrigin, urlType, fileType, clipboardType, spawnerType) { + if (contentOrigin === ObjectContentOrigins.URL) { + return urlType; + } else if (contentOrigin === ObjectContentOrigins.FILE) { + return fileType; + } else if (contentOrigin === ObjectContentOrigins.CLIPBOARD) { + return clipboardType; + } else { + return spawnerType; + } +} + +// Lookup table of mime-type prefixes to the set of object types that we should use +// for objects spawned matching their underlying Content-Type. +const objectTypeMimePrefixLookupMap = { + "image/": [ObjectTypes.URL_IMAGE, ObjectTypes.FILE_IMAGE, ObjectTypes.CLIPBOARD_IMAGE, ObjectTypes.SPAWNER_IMAGE], + "model/": [ObjectTypes.URL_MODEL, ObjectTypes.FILE_MODEL, ObjectTypes.CLIPBOARD_MODEL, ObjectTypes.SPAWNER_MODEL], + "application/x-zip-compressed": [ + ObjectTypes.URL_MODEL, + ObjectTypes.FILE_MODEL, + ObjectTypes.CLIPBOARD_MODEL, + ObjectTypes.SPAWNER_MODEL + ], + "video/": [ObjectTypes.URL_VIDEO, ObjectTypes.FILE_VIDEO, ObjectTypes.CLIPBOARD_VIDEO, ObjectTypes.SPAWNER_VIDEO], + "audio/": [ObjectTypes.URL_AUDIO, ObjectTypes.FILE_AUDIO, ObjectTypes.CLIPBOARD_AUDIO, ObjectTypes.SPAWNER_AUDIO], + "application/pdf": [ObjectTypes.URL_PDF, ObjectTypes.FILE_PDF, ObjectTypes.CLIPBOARD_PDF, ObjectTypes.SPAWNER_PDF] +}; + +// Given an content origin and the resolved mime type of a piece of content, return +// the ObjectType, if any, for that content. +export function objectTypeForOriginAndContentType(contentOrigin, contentType) { + for (const prefix in objectTypeMimePrefixLookupMap) { + if (contentType.startsWith(prefix)) { + const types = objectTypeMimePrefixLookupMap[prefix]; + return objectTypeForOrigin(contentOrigin, ...types); + } + } + + return ObjectTypes.UNKNOWN; +} diff --git a/src/react-components/create-object-dialog.js b/src/react-components/create-object-dialog.js index 16dfa5c1be3e91bca26d63185d7cc6d7e52c33e7..caf31f378b62aaeb5103721a36f182a42a5c8d07 100644 --- a/src/react-components/create-object-dialog.js +++ b/src/react-components/create-object-dialog.js @@ -50,7 +50,8 @@ export default class CreateObjectDialog extends Component { onUrlChange = e => { let attributionImage = this.state.attributionImage; if (e.target && e.target.value && e.target.validity.valid) { - attributionImage = attributionHostnames[new URL(e.target.value).hostname]; + const url = new URL(e.target.value); + attributionImage = attributionHostnames[url.hostname]; } this.setState({ url: e.target && e.target.value, diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js index d8b87730a98dea1feb8f98793323d4b74a139553..86940a0912406cf0b6bd536440606807feaf38f8 100644 --- a/src/utils/hub-channel.js +++ b/src/utils/hub-channel.js @@ -74,6 +74,19 @@ export default class HubChannel { return entryTimingFlags; }; + sendObjectSpawnedEvent = objectType => { + if (!this.channel) { + console.warn("No phoenix channel initialized before object spawn."); + return; + } + + const spawnEvent = { + object_type: objectType + }; + + this.channel.push("events:object_spawned", spawnEvent); + }; + disconnect = () => { if (this.channel) { this.channel.socket.disconnect(); diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js index e9b43552b54a59b1f4fe8358909131de9c941709..92f33b2609ab62cb505322dba8df9179aae5dab1 100644 --- a/src/utils/media-utils.js +++ b/src/utils/media-utils.js @@ -1,3 +1,4 @@ +import { objectTypeForOriginAndContentType } from "../object-types"; let mediaAPIEndpoint = "/api/v1/media"; if (process.env.RETICULUM_SERVER) { @@ -51,7 +52,7 @@ export const upload = file => { }; let interactableId = 0; -export const addMedia = (src, template, resize = false) => { +export const addMedia = (src, template, contentOrigin, resize = false) => { const scene = AFRAME.scenes[0]; const entity = document.createElement("a-entity"); @@ -71,5 +72,11 @@ export const addMedia = (src, template, resize = false) => { entity.setAttribute("media-loader", { src: "error" }); }); } + + entity.addEventListener("media_resolved", ({ detail }) => { + const objectType = objectTypeForOriginAndContentType(contentOrigin, detail.contentType); + scene.emit("object_spawned", { objectType }); + }); + return entity; };