зеркало из https://github.com/mozilla/hubs.git
Merge branch 'master' into feature/drawing
# Conflicts: # package-lock.json # src/hub.html # src/hub.js # src/utils/media-utils.js
This commit is contained in:
Коммит
59b8cfe9d1
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -44,7 +44,7 @@
|
|||
"jsonschema": "^1.2.2",
|
||||
"jszip": "^3.1.5",
|
||||
"moving-average": "^1.0.0",
|
||||
"naf-janus-adapter": "^0.10.2",
|
||||
"naf-janus-adapter": "^0.11.0",
|
||||
"networked-aframe": "github:mozillareality/networked-aframe#mr-social-client/master",
|
||||
"nipplejs": "github:mozillareality/nipplejs#mr-social-client/master",
|
||||
"phoenix": "^1.3.0",
|
||||
|
@ -69,6 +69,7 @@
|
|||
"babel-preset-env": "^1.6.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"copy-webpack-plugin": "^4.5.1",
|
||||
"cors": "^2.8.4",
|
||||
"css-loader": "^1.0.0",
|
||||
"dotenv": "^5.0.1",
|
||||
"eslint": "^5.2.0",
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
@import 'shared';
|
||||
|
||||
:local(.add-media-form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:local(.action-button) {
|
||||
@extend %bottom-action-button;
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
appearance: none;
|
||||
width: 128px;
|
||||
text-align: center;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
:local(.buttons) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:local(.small-button) {
|
||||
margin-left: 0.25em;
|
||||
font-size: 2em;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
:local(.cancel-icon) {
|
||||
color: white;
|
||||
&:hover {
|
||||
color: #FF3D7F
|
||||
}
|
||||
}
|
||||
|
||||
:local(.upload-icon) {
|
||||
color: white;
|
||||
&:hover {
|
||||
color: #2F80ED;
|
||||
}
|
||||
}
|
||||
|
||||
:local(.input-border) {
|
||||
display: flex;
|
||||
border: 0.25em solid white;
|
||||
border-radius: 1em;
|
||||
margin: 1em;
|
||||
padding: 0.5em 0.75em;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@extend %default-font;
|
||||
}
|
||||
|
||||
:local(.left-side-of-input) {
|
||||
flex-grow: 1;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:local(.hide-file-input) {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
}
|
|
@ -75,6 +75,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dialog__box__contents__body p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.invite-form, .add-media-form, .custom-scene-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:local(.invite-nag-button) {
|
||||
:local(.nag-button) {
|
||||
position: absolute;
|
||||
top: 110px;
|
||||
left: 0;
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"entry.invite-others": "invite others",
|
||||
"entry.invite-others-nag": "invite others to join",
|
||||
"entry.enable-screen-sharing": "Share my desktop",
|
||||
"entry.return-to-vr": "Enter in VR",
|
||||
"profile.save": "save",
|
||||
"profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
|
||||
"profile.header": "Your display name:",
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
AFRAME.registerComponent("ambient-light", {
|
||||
schema: {
|
||||
color: { type: "color" },
|
||||
intensity: { default: 1.0 }
|
||||
},
|
||||
|
||||
init() {
|
||||
const el = this.el;
|
||||
this.light = new THREE.AmbientLight();
|
||||
this.el.setObject3D("ambient-light", this.light);
|
||||
this.el.sceneEl.systems.light.registerLight(el);
|
||||
},
|
||||
|
||||
update(prevData) {
|
||||
if (this.data.color !== prevData.color) {
|
||||
this.light.color.set(this.data.color);
|
||||
}
|
||||
|
||||
if (this.data.intensity !== prevData.intensity) {
|
||||
this.light.intensity = this.data.intensity;
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
this.el.removeObject3D("ambient-light");
|
||||
}
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
AFRAME.registerComponent("directional-light", {
|
||||
schema: {
|
||||
color: { type: "color" },
|
||||
intensity: { default: 1.0 },
|
||||
castShadow: { default: true }
|
||||
},
|
||||
|
||||
init() {
|
||||
const el = this.el;
|
||||
this.light = new THREE.DirectionalLight();
|
||||
this.light.position.set(0, 0, 0);
|
||||
this.light.target.position.set(0, 0, 1);
|
||||
this.light.add(this.light.target);
|
||||
this.el.setObject3D("directional-light", this.light);
|
||||
this.el.sceneEl.systems.light.registerLight(el);
|
||||
},
|
||||
|
||||
update(prevData) {
|
||||
if (this.data.color !== prevData.color) {
|
||||
this.light.color.set(this.data.color);
|
||||
}
|
||||
|
||||
if (this.data.intensity !== prevData.intensity) {
|
||||
this.light.intensity = this.data.intensity;
|
||||
}
|
||||
|
||||
if (this.data.castShadow !== prevData.castShadow) {
|
||||
this.light.castShadow = this.data.castShadow;
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
this.el.removeObject3D("directional-light");
|
||||
}
|
||||
});
|
|
@ -215,19 +215,22 @@ async function resolveGLTFUri(gltfProperty, basePath) {
|
|||
if (url.protocol === "blob:") {
|
||||
gltfProperty.uri = url.href;
|
||||
} else {
|
||||
const { raw } = await resolveMedia(url.href);
|
||||
const { raw } = await resolveMedia(url.href, true);
|
||||
gltfProperty.uri = raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGLTF(src, preferredTechnique, onProgress) {
|
||||
const { raw, origin, contentType } = await resolveMedia(src);
|
||||
async function loadGLTF(src, contentType, preferredTechnique, onProgress) {
|
||||
const resolved = await resolveMedia(src);
|
||||
const raw = resolved.raw;
|
||||
const origin = resolved.origin;
|
||||
contentType = contentType || resolved.contentType;
|
||||
const basePath = THREE.LoaderUtils.extractUrlBase(origin);
|
||||
|
||||
let gltfUrl = raw;
|
||||
let fileMap;
|
||||
|
||||
if (contentType === "model/gltf+zip") {
|
||||
if (contentType.includes("model/gltf+zip") || contentType.includes("application/x-zip-compressed")) {
|
||||
fileMap = await getFilesFromSketchfabZip(gltfUrl);
|
||||
gltfUrl = fileMap["scene.gtlf"];
|
||||
}
|
||||
|
@ -247,13 +250,17 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
|
|||
|
||||
if (images) {
|
||||
for (const image of images) {
|
||||
pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path));
|
||||
if (image.uri) {
|
||||
pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffers) {
|
||||
for (const buffer of buffers) {
|
||||
pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path));
|
||||
if (buffer.uri) {
|
||||
pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -278,8 +285,6 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
|
|||
|
||||
await Promise.all(pendingFarsparkPromises);
|
||||
|
||||
console.log(parser.json);
|
||||
|
||||
const gltf = await new Promise((resolve, reject) =>
|
||||
parser.parse(
|
||||
(scene, scenes, cameras, animations, json) => {
|
||||
|
@ -317,6 +322,7 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
|
|||
AFRAME.registerComponent("gltf-model-plus", {
|
||||
schema: {
|
||||
src: { type: "string" },
|
||||
contentType: { type: "string" },
|
||||
inflate: { default: false }
|
||||
},
|
||||
|
||||
|
@ -327,7 +333,7 @@ AFRAME.registerComponent("gltf-model-plus", {
|
|||
},
|
||||
|
||||
update() {
|
||||
this.applySrc(this.data.src);
|
||||
this.applySrc(this.data.src, this.data.contentType);
|
||||
},
|
||||
|
||||
loadTemplates() {
|
||||
|
@ -338,7 +344,7 @@ AFRAME.registerComponent("gltf-model-plus", {
|
|||
});
|
||||
},
|
||||
|
||||
async applySrc(src) {
|
||||
async applySrc(src, contentType) {
|
||||
try {
|
||||
// If the src attribute is a selector, get the url from the asset item.
|
||||
if (src && src.charAt(0) === "#") {
|
||||
|
@ -360,7 +366,7 @@ AFRAME.registerComponent("gltf-model-plus", {
|
|||
const gltfPath = THREE.LoaderUtils.extractUrlBase(src);
|
||||
|
||||
if (!GLTFCache[src]) {
|
||||
GLTFCache[src] = loadGLTF(src, this.preferredTechnique);
|
||||
GLTFCache[src] = loadGLTF(src, contentType, this.preferredTechnique);
|
||||
}
|
||||
|
||||
const model = cloneGltf(await GLTFCache[src]);
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
AFRAME.registerComponent("hemisphere-light", {
|
||||
schema: {
|
||||
skyColor: { type: "color" },
|
||||
groundColor: { type: "color" },
|
||||
intensity: { default: 1.0 }
|
||||
},
|
||||
|
||||
init() {
|
||||
const el = this.el;
|
||||
this.light = new THREE.HemisphereLight();
|
||||
this.light.position.set(0, 0, 0);
|
||||
this.el.setObject3D("hemisphere-light", this.light);
|
||||
this.el.sceneEl.systems.light.registerLight(el);
|
||||
},
|
||||
|
||||
update(prevData) {
|
||||
if (this.data.skyColor !== prevData.skyColor) {
|
||||
this.light.color.set(this.data.skyColor);
|
||||
}
|
||||
|
||||
if (this.data.groundColor !== prevData.groundColor) {
|
||||
this.light.groundColor.set(this.data.groundColor);
|
||||
}
|
||||
|
||||
if (this.data.intensity !== prevData.intensity) {
|
||||
this.light.intensity = this.data.intensity;
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
this.el.removeObject3D("hemisphere-light");
|
||||
}
|
||||
});
|
|
@ -36,6 +36,41 @@ class GIFTexture extends THREE.Texture {
|
|||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
@ -53,8 +88,42 @@ function createVideoEl(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();
|
||||
|
||||
|
@ -74,138 +143,76 @@ AFRAME.registerComponent("image-plus", {
|
|||
depth: { default: 0.05 }
|
||||
},
|
||||
|
||||
remove() {
|
||||
const material = this.el.getObject3D("mesh").material;
|
||||
const texture = material.map;
|
||||
releaseTexture(src) {
|
||||
if (this.mesh && this.mesh.material.map !== errorTexture) {
|
||||
this.mesh.material.map = null;
|
||||
this.mesh.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
if (texture === errorTexture) return;
|
||||
if (!textureCache.has(src)) return;
|
||||
|
||||
const url = texture.image.src;
|
||||
const cacheItem = textureCache.get(url);
|
||||
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 (texture.image instanceof HTMLVideoElement) {
|
||||
const video = texture.image;
|
||||
if (cacheItem.texture.image instanceof HTMLVideoElement) {
|
||||
const video = cacheItem.texture.image;
|
||||
video.pause();
|
||||
video.src = "";
|
||||
video.load();
|
||||
}
|
||||
|
||||
texture.dispose();
|
||||
cacheItem.texture.dispose();
|
||||
|
||||
// THREE never lets go of material refs, long running PR HERE https://github.com/mrdoob/three.js/pull/12464
|
||||
// Mitigate the damage a bit by at least breaking the image ref so Image/Video elements can be freed
|
||||
// TODO: If/when THREE gets fixed, we should be able to safely remove this
|
||||
delete texture.image;
|
||||
|
||||
textureCache.delete(url);
|
||||
textureCache.delete(src);
|
||||
}
|
||||
},
|
||||
|
||||
async loadGIF(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);
|
||||
});
|
||||
remove() {
|
||||
this.releaseTexture(this.data.src);
|
||||
},
|
||||
|
||||
loadVideo(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 (
|
||||
this.el.sceneEl.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;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadImage(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})`);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async update() {
|
||||
async update(oldData) {
|
||||
let texture;
|
||||
try {
|
||||
const url = this.data.src;
|
||||
const contentType = this.data.contentType;
|
||||
if (!url) {
|
||||
return;
|
||||
const { src, contentType } = this.data;
|
||||
if (!src) return;
|
||||
|
||||
if (this.mesh) {
|
||||
this.releaseTexture(oldData.src);
|
||||
}
|
||||
|
||||
let cacheItem;
|
||||
if (textureCache.has(url)) {
|
||||
cacheItem = textureCache.get(url);
|
||||
if (textureCache.has(src)) {
|
||||
cacheItem = textureCache.get(src);
|
||||
texture = cacheItem.texture;
|
||||
cacheItem.count++;
|
||||
} else {
|
||||
cacheItem = { count: 1 };
|
||||
if (url === "error") {
|
||||
if (src === "error") {
|
||||
texture = errorTexture;
|
||||
} else if (contentType === "image/gif") {
|
||||
texture = await this.loadGIF(url);
|
||||
} else if (contentType.includes("image/gif")) {
|
||||
texture = await createGIFTexture(src);
|
||||
} else if (contentType.startsWith("image/")) {
|
||||
texture = await this.loadImage(url);
|
||||
texture = await createImageTexture(src);
|
||||
} else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) {
|
||||
texture = await this.loadVideo(url);
|
||||
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(url, cacheItem);
|
||||
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) {
|
||||
|
@ -218,22 +225,31 @@ AFRAME.registerComponent("image-plus", {
|
|||
texture = errorTexture;
|
||||
}
|
||||
|
||||
const material = new THREE.MeshBasicMaterial();
|
||||
material.side = THREE.DoubleSide;
|
||||
material.transparent = true;
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
material.map.needsUpdate = true;
|
||||
|
||||
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);
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(width, height, 1, 1);
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
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 }
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { getBox, getScaleCoefficient } from "../utils/auto-box-collider";
|
||||
import { resolveMedia } from "../utils/media-utils";
|
||||
import { resolveMedia, fetchMaxContentIndex } from "../utils/media-utils";
|
||||
|
||||
AFRAME.registerComponent("media-loader", {
|
||||
schema: {
|
||||
src: { type: "string" },
|
||||
index: { type: "number" },
|
||||
resize: { default: false }
|
||||
},
|
||||
|
||||
init() {
|
||||
this.onError = this.onError.bind(this);
|
||||
this.showLoader = this.showLoader.bind(this);
|
||||
},
|
||||
|
||||
setShapeAndScale(resize) {
|
||||
|
@ -36,45 +38,83 @@ AFRAME.registerComponent("media-loader", {
|
|||
},
|
||||
|
||||
onError() {
|
||||
this.el.removeAttribute("gltf-model-plus");
|
||||
this.el.removeAttribute("media-pager");
|
||||
this.el.setAttribute("image-plus", { src: "error" });
|
||||
clearTimeout(this.showLoaderTimeout);
|
||||
delete this.showLoaderTimeout;
|
||||
},
|
||||
|
||||
// TODO: correctly handle case where src changes
|
||||
async update() {
|
||||
showLoader() {
|
||||
const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
|
||||
this.el.setObject3D("mesh", loadingObj);
|
||||
this.setShapeAndScale(true);
|
||||
delete this.showLoaderTimeout;
|
||||
},
|
||||
|
||||
async update(oldData) {
|
||||
try {
|
||||
const url = this.data.src;
|
||||
const { src, index } = this.data;
|
||||
|
||||
this.showLoaderTimeout = setTimeout(() => {
|
||||
const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
|
||||
this.el.setObject3D("mesh", loadingObj);
|
||||
this.setShapeAndScale(true);
|
||||
}, 100);
|
||||
if (src !== oldData.src && !this.showLoaderTimeout) {
|
||||
this.showLoaderTimeout = setTimeout(this.showLoader, 100);
|
||||
}
|
||||
|
||||
const { raw, contentType } = await resolveMedia(url);
|
||||
if (!src) return;
|
||||
|
||||
if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) {
|
||||
const { raw, images, contentType } = await resolveMedia(src, false, index);
|
||||
|
||||
const isPDF = contentType.startsWith("application/pdf");
|
||||
if (
|
||||
contentType.startsWith("image/") ||
|
||||
contentType.startsWith("video/") ||
|
||||
contentType.startsWith("audio/") ||
|
||||
isPDF
|
||||
) {
|
||||
this.el.removeAttribute("gltf-model-plus");
|
||||
this.el.addEventListener(
|
||||
"image-loaded",
|
||||
() => {
|
||||
async () => {
|
||||
clearTimeout(this.showLoaderTimeout);
|
||||
delete this.showLoaderTimeout;
|
||||
if (isPDF) {
|
||||
const maxIndex = await fetchMaxContentIndex(src, images.png);
|
||||
this.el.setAttribute("media-pager", { index, maxIndex });
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
this.el.setAttribute("image-plus", { src: raw, contentType });
|
||||
this.el.setAttribute("position-at-box-shape-border", { target: ".delete-button", dirs: ["forward", "back"] });
|
||||
} else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) {
|
||||
const imageSrc = isPDF ? images.png : raw;
|
||||
const imageContentType = isPDF ? "image/png" : contentType;
|
||||
|
||||
if (!isPDF) {
|
||||
this.el.removeAttribute("media-pager");
|
||||
}
|
||||
|
||||
this.el.setAttribute("image-plus", { src: imageSrc, contentType: imageContentType });
|
||||
this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
|
||||
} else if (
|
||||
contentType.includes("application/octet-stream") ||
|
||||
contentType.includes("x-zip-compressed") ||
|
||||
contentType.startsWith("model/gltf") ||
|
||||
src.endsWith(".gltf") ||
|
||||
src.endsWith(".glb")
|
||||
) {
|
||||
this.el.removeAttribute("image-plus");
|
||||
this.el.removeAttribute("media-pager");
|
||||
this.el.addEventListener(
|
||||
"model-loaded",
|
||||
() => {
|
||||
clearTimeout(this.showLoaderTimeout);
|
||||
delete this.showLoaderTimeout;
|
||||
this.setShapeAndScale(this.data.resize);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
this.el.addEventListener("model-error", this.onError, { once: true });
|
||||
this.el.setAttribute("gltf-model-plus", {
|
||||
src: url, // gltf-model-plus expects the unresolved gltf url. The resolved farspark URL will be retrieved from the cache.
|
||||
src,
|
||||
contentType,
|
||||
inflate: true
|
||||
});
|
||||
} else {
|
||||
|
@ -86,3 +126,54 @@ AFRAME.registerComponent("media-loader", {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
AFRAME.registerComponent("media-pager", {
|
||||
schema: {
|
||||
index: { type: "string" },
|
||||
maxIndex: { type: "string" }
|
||||
},
|
||||
|
||||
init() {
|
||||
this.onNext = this.onNext.bind(this);
|
||||
this.onPrev = this.onPrev.bind(this);
|
||||
|
||||
const template = document.getElementById("paging-toolbar");
|
||||
this.el.appendChild(document.importNode(template.content, true));
|
||||
this.toolbar = this.el.querySelector(".paging-toolbar");
|
||||
// we have to wait a tick for the attach callbacks to get fired for the elements in a template
|
||||
setTimeout(() => {
|
||||
this.nextButton = this.el.querySelector(".next-button [text-button]");
|
||||
this.prevButton = this.el.querySelector(".prev-button [text-button]");
|
||||
this.pageLabel = this.el.querySelector(".page-label");
|
||||
|
||||
this.nextButton.addEventListener("click", this.onNext);
|
||||
this.prevButton.addEventListener("click", this.onPrev);
|
||||
|
||||
this.update();
|
||||
}, 0);
|
||||
},
|
||||
|
||||
update() {
|
||||
if (!this.pageLabel) return;
|
||||
this.pageLabel.setAttribute("text", "value", `${this.data.index + 1}/${this.data.maxIndex + 1}`);
|
||||
this.repositionToolbar();
|
||||
},
|
||||
|
||||
remove() {
|
||||
this.nextButton.removeEventListener("click", this.onNext);
|
||||
this.prevButton.removeEventListener("click", this.onPrev);
|
||||
this.el.removeChild(this.toolbar);
|
||||
},
|
||||
|
||||
onNext() {
|
||||
this.el.setAttribute("media-loader", "index", Math.min(this.data.index + 1, this.data.maxIndex));
|
||||
},
|
||||
|
||||
onPrev() {
|
||||
this.el.setAttribute("media-loader", "index", Math.max(this.data.index - 1, 0));
|
||||
},
|
||||
|
||||
repositionToolbar() {
|
||||
this.toolbar.object3D.position.y = -this.el.getAttribute("shape").halfExtents.y - 0.2;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -38,9 +38,6 @@ AFRAME.registerComponent("offset-relative-to", {
|
|||
}
|
||||
obj.position.copy(offsetVector);
|
||||
this.el.body && this.el.body.position.copy(obj.position);
|
||||
// TODO: Hack here to deal with the fact that the rotation component mutates ordering, and we network rotation without sending ordering information
|
||||
// See https://github.com/networked-aframe/networked-aframe/issues/134
|
||||
obj.rotation.order = "YXZ";
|
||||
target.getWorldQuaternion(obj.quaternion);
|
||||
this.el.body && this.el.body.quaternion.copy(obj.quaternion);
|
||||
if (this.data.selfDestruct) {
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
AFRAME.registerComponent("point-light", {
|
||||
schema: {
|
||||
color: { type: "color" },
|
||||
intensity: { default: 1.0 },
|
||||
range: { default: 0 },
|
||||
castShadow: { default: true }
|
||||
},
|
||||
|
||||
init() {
|
||||
const el = this.el;
|
||||
this.light = new THREE.PointLight();
|
||||
this.light.decay = 2;
|
||||
this.el.setObject3D("point-light", this.light);
|
||||
this.el.sceneEl.systems.light.registerLight(el);
|
||||
},
|
||||
|
||||
update(prevData) {
|
||||
if (this.data.color !== prevData.color) {
|
||||
this.light.color.set(this.data.color);
|
||||
}
|
||||
|
||||
if (this.data.intensity !== prevData.intensity) {
|
||||
this.light.intensity = this.data.intensity;
|
||||
}
|
||||
|
||||
if (this.data.range !== prevData.range) {
|
||||
this.light.distance = this.data.range;
|
||||
}
|
||||
|
||||
if (this.data.castShadow !== prevData.castShadow) {
|
||||
this.light.castShadow = this.data.castShadow;
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
this.el.removeObject3D("point-light");
|
||||
}
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
AFRAME.registerComponent("spot-light", {
|
||||
schema: {
|
||||
color: { type: "color" },
|
||||
intensity: { default: 1.0 },
|
||||
range: { default: 0 },
|
||||
innerConeAngle: { default: 0 },
|
||||
outerConeAngle: { default: Math.PI / 4.0 },
|
||||
castShadow: { default: true }
|
||||
},
|
||||
|
||||
init() {
|
||||
const el = this.el;
|
||||
this.light = new THREE.SpotLight();
|
||||
this.light.target.position.set(0, 0, 1);
|
||||
this.light.add(this.light.target);
|
||||
this.light.decay = 2;
|
||||
this.el.setObject3D("spot-light", this.light);
|
||||
this.el.sceneEl.systems.light.registerLight(el);
|
||||
},
|
||||
|
||||
update(prevData) {
|
||||
if (this.data.color !== prevData.color) {
|
||||
this.light.color.set(this.data.color);
|
||||
}
|
||||
|
||||
if (this.data.intensity !== prevData.intensity) {
|
||||
this.light.intensity = this.data.intensity;
|
||||
}
|
||||
|
||||
if (this.data.range !== prevData.range) {
|
||||
this.light.distance = this.data.range;
|
||||
}
|
||||
|
||||
if (this.data.innerConeAngle !== prevData.innerConeAngle || this.data.outerConeAngle !== prevData.outerConeAngle) {
|
||||
this.light.angle = this.data.outerConeAngle;
|
||||
this.light.penumbra = 1.0 - this.data.innerConeAngle / this.data.outerConeAngle;
|
||||
}
|
||||
|
||||
if (this.data.castShadow !== prevData.castShadow) {
|
||||
this.light.castShadow = this.data.castShadow;
|
||||
}
|
||||
},
|
||||
|
||||
remove: function() {
|
||||
this.el.removeObject3D("spot-light");
|
||||
}
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Toggles the visibility of this entity based on networked ownership
|
||||
* @namespace ui
|
||||
* @component visible-while-frozen
|
||||
*/
|
||||
AFRAME.registerComponent("visible-to-owner", {
|
||||
init() {
|
||||
this.onStateChange = e => {
|
||||
this.el.setAttribute("visible", e.detail.newOwner === NAF.clientId);
|
||||
};
|
||||
NAF.utils.getNetworkedEntity(this.el).then(el => {
|
||||
el.addEventListener("ownership-changed", this.onStateChange);
|
||||
this.el.setAttribute("visible", NAF.utils.isMine(el));
|
||||
});
|
||||
}
|
||||
});
|
|
@ -11,9 +11,11 @@ AFRAME.GLTFModelPlus.registerComponent("gltf-model-plus", "gltf-model-plus");
|
|||
AFRAME.GLTFModelPlus.registerComponent("body", "body");
|
||||
AFRAME.GLTFModelPlus.registerComponent("hide-when-quality", "hide-when-quality");
|
||||
AFRAME.GLTFModelPlus.registerComponent("light", "light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("directional-light", "light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("ambient-light", "light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("point-light", "light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("ambient-light", "ambient-light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("directional-light", "directional-light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("hemisphere-light", "hemisphere-light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("point-light", "point-light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("spot-light", "spot-light");
|
||||
AFRAME.GLTFModelPlus.registerComponent("skybox", "skybox");
|
||||
AFRAME.GLTFModelPlus.registerComponent("layers", "layers");
|
||||
AFRAME.GLTFModelPlus.registerComponent("shadow", "shadow");
|
||||
|
|
15
src/hub.html
15
src/hub.html
|
@ -176,7 +176,9 @@
|
|||
sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
|
||||
position-at-box-shape-border="target:.delete-button"
|
||||
destroy-at-extreme-distances
|
||||
rotation
|
||||
>
|
||||
<!-- HACK: rotation component above is required for its side effect of setting YXZ order -->
|
||||
<a-entity class="delete-button" visible-while-frozen>
|
||||
<a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
|
||||
<a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
|
||||
|
@ -216,6 +218,19 @@
|
|||
></a-entity>
|
||||
</template>
|
||||
|
||||
<template id="paging-toolbar">
|
||||
<a-entity class="paging-toolbar" visible-to-owner>
|
||||
<a-entity class="prev-button" position="-0.3 0 0">
|
||||
<a-entity mixin="rounded-text-button" slice9="width: 0.2"> </a-entity>
|
||||
<a-entity text=" value:<; width:2; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
|
||||
</a-entity>
|
||||
<a-entity class="page-label" text="width:2; align:center;" text-raycast-hack></a-entity>
|
||||
<a-entity class="next-button" position="0.3 0 0">
|
||||
<a-entity mixin="rounded-text-button" slice9="width: 0.2"> </a-entity>
|
||||
<a-entity text=" value:>; width:2; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
|
||||
</a-entity>
|
||||
</a-entity>
|
||||
</template>
|
||||
|
||||
<a-mixin id="rounded-text-button"
|
||||
text-button="
|
||||
|
|
59
src/hub.js
59
src/hub.js
|
@ -75,6 +75,12 @@ import "./components/remove-networked-object-button";
|
|||
import "./components/destroy-at-extreme-distances";
|
||||
import "./components/media-loader";
|
||||
import "./components/gamma-factor";
|
||||
import "./components/ambient-light";
|
||||
import "./components/directional-light";
|
||||
import "./components/hemisphere-light";
|
||||
import "./components/point-light";
|
||||
import "./components/spot-light";
|
||||
import "./components/visible-to-owner";
|
||||
|
||||
import ReactDOM from "react-dom";
|
||||
import React from "react";
|
||||
|
@ -303,8 +309,9 @@ const onReady = async () => {
|
|||
});
|
||||
|
||||
const offset = { x: 0, y: 0, z: -1.5 };
|
||||
const spawnMediaInfrontOfPlayer = url => {
|
||||
const entity = addMedia(url, "#interactable-media", true);
|
||||
|
||||
const spawnMediaInfrontOfPlayer = src => {
|
||||
const entity = addMedia(src, "#interactable-media", true);
|
||||
entity.setAttribute("offset-relative-to", {
|
||||
target: "#player-camera",
|
||||
offset
|
||||
|
@ -315,28 +322,36 @@ const onReady = async () => {
|
|||
spawnMediaInfrontOfPlayer(e.detail);
|
||||
});
|
||||
|
||||
if (qsTruthy("mediaTools")) {
|
||||
document.addEventListener("paste", e => {
|
||||
if (e.target.nodeName === "INPUT") return;
|
||||
document.addEventListener("paste", e => {
|
||||
if (e.target.nodeName === "INPUT") return;
|
||||
|
||||
const imgUrl = e.clipboardData.getData("text");
|
||||
console.log("Pasted: ", imgUrl, e);
|
||||
spawnMediaInfrontOfPlayer(imgUrl);
|
||||
});
|
||||
|
||||
document.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
const imgUrl = e.dataTransfer.getData("url");
|
||||
if (imgUrl) {
|
||||
console.log("Dropped: ", imgUrl);
|
||||
spawnMediaInfrontOfPlayer(imgUrl);
|
||||
const url = e.clipboardData.getData("text");
|
||||
const files = e.clipboardData.files && e.clipboardData.files;
|
||||
if (url) {
|
||||
spawnMediaInfrontOfPlayer(url);
|
||||
} else {
|
||||
for (const file of files) {
|
||||
spawnMediaInfrontOfPlayer(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
const url = e.dataTransfer.getData("url");
|
||||
const files = e.dataTransfer.files;
|
||||
if (url) {
|
||||
spawnMediaInfrontOfPlayer(url);
|
||||
} else {
|
||||
for (const file of files) {
|
||||
spawnMediaInfrontOfPlayer(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!qsTruthy("offline")) {
|
||||
document.body.addEventListener("connected", () => {
|
||||
|
|
|
@ -3,7 +3,6 @@ import PropTypes from "prop-types";
|
|||
import cx from "classnames";
|
||||
|
||||
import styles from "../assets/stylesheets/2d-hud.scss";
|
||||
import qsTruthy from "../utils/qs_truthy";
|
||||
|
||||
const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
|
||||
<div className={cx(styles.container, styles.top)}>
|
||||
|
@ -40,13 +39,11 @@ TopHUD.propTypes = {
|
|||
|
||||
const BottomHUD = ({ onCreateObject }) => (
|
||||
<div className={cx(styles.container, styles.bottom)}>
|
||||
{qsTruthy("mediaTools") && (
|
||||
<div
|
||||
className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
|
||||
title={"Create Object"}
|
||||
onClick={onCreateObject}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
|
||||
title={"Create Object"}
|
||||
onClick={onCreateObject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -1,19 +1,37 @@
|
|||
import React, { Component } from "react";
|
||||
import "aframe";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import giphyLogo from "../assets/images/giphy_logo.png";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPaperclip, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import styles from "../assets/stylesheets/create-object-dialog.scss";
|
||||
import cx from "classnames";
|
||||
|
||||
const attributionHostnames = {
|
||||
"giphy.com": giphyLogo
|
||||
"giphy.com": giphyLogo,
|
||||
"media.giphy.com": giphyLogo
|
||||
};
|
||||
|
||||
const DEFAULT_OBJECT_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf";
|
||||
const isMobile = AFRAME.utils.device.isMobile();
|
||||
const instructions = "Paste a URL or upload a file.";
|
||||
const desktopTips = "Tip: You can paste links directly into Hubs with Ctrl+V";
|
||||
const mobileInstructions = <div>{instructions}</div>;
|
||||
const desktopInstructions = (
|
||||
<div>
|
||||
<p>{instructions}</p>
|
||||
<p>{desktopTips}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
let lastUrl = "";
|
||||
const fileInputId = "file-input";
|
||||
|
||||
export default class CreateObjectDialog extends Component {
|
||||
state = {
|
||||
url: ""
|
||||
url: "",
|
||||
file: null,
|
||||
fileName: ""
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
|
@ -22,9 +40,7 @@ export default class CreateObjectDialog extends Component {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ url: lastUrl }, () => {
|
||||
this.onUrlChange({ target: this.input });
|
||||
});
|
||||
this.setState({ url: lastUrl });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -32,44 +48,79 @@ export default class CreateObjectDialog extends Component {
|
|||
}
|
||||
|
||||
onUrlChange = e => {
|
||||
if (e && e.target.value && e.target.value !== "") {
|
||||
this.setState({
|
||||
url: e.target.value,
|
||||
attributionImage: e.target.validity.valid && attributionHostnames[new URL(e.target.value).hostname]
|
||||
});
|
||||
let attributionImage = this.state.attributionImage;
|
||||
if (e.target && e.target.value && e.target.validity.valid) {
|
||||
attributionImage = attributionHostnames[new URL(e.target.value).hostname];
|
||||
}
|
||||
this.setState({
|
||||
url: e.target && e.target.value,
|
||||
attributionImage: attributionImage
|
||||
});
|
||||
};
|
||||
|
||||
onCreateClicked = () => {
|
||||
this.props.onCreateObject(this.state.url || DEFAULT_OBJECT_URL);
|
||||
onFileChange = e => {
|
||||
this.setState({
|
||||
file: e.target.files[0],
|
||||
fileName: e.target.files[0].name
|
||||
});
|
||||
};
|
||||
|
||||
onCreateClicked = e => {
|
||||
e.preventDefault();
|
||||
this.props.onCreateObject(this.state.file || this.state.url || DEFAULT_OBJECT_URL);
|
||||
this.props.onCloseDialog();
|
||||
};
|
||||
|
||||
reset = e => {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
url: "",
|
||||
file: null,
|
||||
fileName: ""
|
||||
});
|
||||
this.fileInput.value = null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const cancelButton = (
|
||||
<label className={cx(styles.smallButton, styles.cancelIcon)} onClick={this.reset}>
|
||||
<FontAwesomeIcon icon={faTimes} />
|
||||
</label>
|
||||
);
|
||||
const uploadButton = (
|
||||
<label htmlFor={fileInputId} className={cx(styles.smallButton, styles.uploadIcon)}>
|
||||
<FontAwesomeIcon icon={faPaperclip} />
|
||||
</label>
|
||||
);
|
||||
const filenameLabel = <label className={cx(styles.leftSideOfInput)}>{this.state.fileName}</label>;
|
||||
const urlInput = (
|
||||
<input
|
||||
className={cx(styles.leftSideOfInput)}
|
||||
placeholder="Image/Video/glTF URL"
|
||||
onChange={this.onUrlChange}
|
||||
type="url"
|
||||
value={this.state.url}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!AFRAME.utils.device.isMobile() ? (
|
||||
<div>
|
||||
Paste a URL from the web to create an object in the room.
|
||||
<br />
|
||||
Tip: You can paste directly into Hubs using Ctrl+V
|
||||
</div>
|
||||
) : (
|
||||
<div>Paste a URL from the web to create an object in the room.</div>
|
||||
)}
|
||||
|
||||
{isMobile ? mobileInstructions : desktopInstructions}
|
||||
<form onSubmit={this.onCreateClicked}>
|
||||
<div className="add-media-form">
|
||||
<div className={styles.addMediaForm}>
|
||||
<input
|
||||
ref={el => (this.input = el)}
|
||||
type="url"
|
||||
placeholder="Image, Video, or GLTF URL"
|
||||
className="add-media-form__link_field"
|
||||
value={this.state.url}
|
||||
onChange={this.onUrlChange}
|
||||
id={fileInputId}
|
||||
ref={f => (this.fileInput = f)}
|
||||
className={styles.hideFileInput}
|
||||
type="file"
|
||||
onChange={this.onFileChange}
|
||||
/>
|
||||
<div className="add-media-form__buttons">
|
||||
<button className="add-media-form__action-button">
|
||||
<div className={styles.inputBorder}>
|
||||
{this.state.file ? filenameLabel : urlInput}
|
||||
{this.state.url || this.state.fileName ? cancelButton : uploadButton}
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<button className={styles.actionButton}>
|
||||
<span>create</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -123,18 +123,26 @@ class InfoDialog extends Component {
|
|||
dialogTitle = "Get in Touch";
|
||||
dialogBody = (
|
||||
<span>
|
||||
Want to join the conversation?
|
||||
<p />
|
||||
Join us on the{" "}
|
||||
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
|
||||
WebVR Slack
|
||||
</a>{" "}
|
||||
in the #social channel.<br />VR meetups every Friday at noon PST!
|
||||
<p /> Or, tweet at{" "}
|
||||
<a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
|
||||
@mozillareality
|
||||
</a>{" "}
|
||||
on Twitter.
|
||||
<p>Want to join the conversation?</p>
|
||||
<p>
|
||||
Join us on the{" "}
|
||||
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
|
||||
WebVR Slack
|
||||
</a>{" "}
|
||||
in the{" "}
|
||||
<a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
|
||||
#social
|
||||
</a>{" "}
|
||||
channel.<br />
|
||||
VR meetups every Friday at noon PDT!
|
||||
</p>
|
||||
<p>
|
||||
Or, tweet at{" "}
|
||||
<a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
|
||||
@mozillareality
|
||||
</a>{" "}
|
||||
on Twitter.
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
break;
|
||||
|
@ -241,8 +249,7 @@ class InfoDialog extends Component {
|
|||
dialogTitle = "";
|
||||
dialogBody = (
|
||||
<span>
|
||||
Sign up to get updates about new features in Hubs.
|
||||
<p />
|
||||
<p>Sign up to get updates about new features in Hubs.</p>
|
||||
<form onSubmit={this.signUpForMailingList}>
|
||||
<div className="mailing-list-form">
|
||||
<input
|
||||
|
@ -278,18 +285,24 @@ class InfoDialog extends Component {
|
|||
dialogTitle = "Report an Issue";
|
||||
dialogBody = (
|
||||
<span>
|
||||
Need to report a problem?
|
||||
<p />
|
||||
You can file a{" "}
|
||||
<a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
|
||||
Github Issue
|
||||
</a>{" "}
|
||||
or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
|
||||
<p />
|
||||
You can also find us in #social on the{" "}
|
||||
<a href="http://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
|
||||
WebVR Slack
|
||||
</a>.
|
||||
<p>Need to report a problem?</p>
|
||||
<p>
|
||||
You can file a{" "}
|
||||
<a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
|
||||
GitHub Issue
|
||||
</a>{" "}
|
||||
or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
|
||||
</p>
|
||||
<p>
|
||||
You can also find us in{" "}
|
||||
<a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
|
||||
#social
|
||||
</a>{" "}
|
||||
on the{" "}
|
||||
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
|
||||
WebVR Slack
|
||||
</a>.
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
break;
|
||||
|
@ -297,10 +310,11 @@ class InfoDialog extends Component {
|
|||
dialogTitle = "Getting Started";
|
||||
dialogBody = (
|
||||
<div className="info-dialog__help">
|
||||
When in a room, other avatars can see and hear you.
|
||||
<p />
|
||||
Use your controller's action button to teleport from place to place. If it has a trigger, use it to
|
||||
pick up objects.
|
||||
<p>When in a room, other avatars can see and hear you.</p>
|
||||
<p>
|
||||
Use your controller's action button to teleport from place to place. If it has a trigger, use it to
|
||||
pick up objects.
|
||||
</p>
|
||||
<p style={{ textAlign: "center" }}>
|
||||
In VR, <b>look up</b> to find your menu:
|
||||
<img
|
||||
|
|
|
@ -893,10 +893,18 @@ class UIRoot extends Component {
|
|||
onToggleFreeze={this.toggleFreeze}
|
||||
onToggleSpaceBubble={this.toggleSpaceBubble}
|
||||
/>
|
||||
{this.props.occupantCount <= 1 && (
|
||||
<div className={styles.inviteNagButton}>
|
||||
<button onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}>
|
||||
<FormattedMessage id="entry.invite-others-nag" />
|
||||
{!this.props.availableVREntryTypes.isInHMD &&
|
||||
this.props.occupantCount <= 1 && (
|
||||
<div className={styles.nagButton}>
|
||||
<button onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}>
|
||||
<FormattedMessage id="entry.invite-others-nag" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{this.props.availableVREntryTypes.isInHMD && (
|
||||
<div className={styles.nagButton}>
|
||||
<button onClick={() => this.props.scene.enterVR()}>
|
||||
<FormattedMessage id="entry.return-to-vr" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,34 +1,55 @@
|
|||
const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/];
|
||||
const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length;
|
||||
let mediaAPIEndpoint = "/api/v1/media";
|
||||
|
||||
if (process.env.RETICULUM_SERVER) {
|
||||
mediaAPIEndpoint = `https://${process.env.RETICULUM_SERVER}${mediaAPIEndpoint}`;
|
||||
}
|
||||
|
||||
const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
|
||||
const fetchContentType = async url => {
|
||||
return fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
|
||||
};
|
||||
|
||||
const contentIndexCache = new Map();
|
||||
export const fetchMaxContentIndex = async (documentUrl, pageUrl) => {
|
||||
if (contentIndexCache.has(documentUrl)) return contentIndexCache.get(documentUrl);
|
||||
const maxIndex = await fetch(pageUrl).then(r => parseInt(r.headers.get("x-max-content-index")));
|
||||
contentIndexCache.set(documentUrl, maxIndex);
|
||||
return maxIndex;
|
||||
};
|
||||
|
||||
const resolveMediaCache = new Map();
|
||||
export const resolveMedia = async url => {
|
||||
export const resolveMedia = async (url, skipContentType, index) => {
|
||||
const parsedUrl = new URL(url);
|
||||
if (resolveMediaCache.has(url)) return resolveMediaCache.get(url);
|
||||
const cacheKey = `${url}|${index}`;
|
||||
if (resolveMediaCache.has(cacheKey)) return resolveMediaCache.get(cacheKey);
|
||||
|
||||
const resolved =
|
||||
(parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname)
|
||||
? { raw: url, origin: url }
|
||||
: await fetch(mediaAPIEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ media: { url } })
|
||||
}).then(r => r.json());
|
||||
const isHttpOrHttps = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
|
||||
const resolved = !isHttpOrHttps
|
||||
? { raw: url, origin: url }
|
||||
: await fetch(mediaAPIEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ media: { url, index } })
|
||||
}).then(r => r.json());
|
||||
|
||||
const contentType = (resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw));
|
||||
resolved.contentType = contentType;
|
||||
if (isHttpOrHttps && !skipContentType) {
|
||||
const contentType =
|
||||
(resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw));
|
||||
resolved.contentType = contentType;
|
||||
}
|
||||
|
||||
resolveMediaCache.set(url, resolved);
|
||||
resolveMediaCache.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const upload = file => {
|
||||
const formData = new FormData();
|
||||
formData.append("media", file);
|
||||
return fetch(mediaAPIEndpoint, {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(r => r.json());
|
||||
};
|
||||
|
||||
let interactableId = 0;
|
||||
export const addMedia = (src, template, resize = false) => {
|
||||
const scene = AFRAME.scenes[0];
|
||||
|
@ -36,7 +57,19 @@ export const addMedia = (src, template, resize = false) => {
|
|||
const entity = document.createElement("a-entity");
|
||||
entity.id = "interactable-media-" + interactableId++;
|
||||
entity.setAttribute("networked", { template: template });
|
||||
entity.setAttribute("media-loader", { src, resize });
|
||||
entity.setAttribute("media-loader", { resize, src: typeof src === "string" ? src : "" });
|
||||
scene.appendChild(entity);
|
||||
|
||||
if (src instanceof File) {
|
||||
upload(src)
|
||||
.then(response => {
|
||||
const srcUrl = new URL(response.raw);
|
||||
srcUrl.searchParams.set("token", response.meta.access_token);
|
||||
entity.setAttribute("media-loader", { src: srcUrl.href });
|
||||
})
|
||||
.catch(() => {
|
||||
entity.setAttribute("media-loader", { src: "error" });
|
||||
});
|
||||
}
|
||||
return entity;
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ const fs = require("fs");
|
|||
const path = require("path");
|
||||
const selfsigned = require("selfsigned");
|
||||
const webpack = require("webpack");
|
||||
const cors = require("cors");
|
||||
const HTMLWebpackPlugin = require("html-webpack-plugin");
|
||||
const ExtractTextPlugin = require("extract-text-webpack-plugin");
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
|
@ -76,8 +77,9 @@ module.exports = (env, argv) => ({
|
|||
host: "0.0.0.0",
|
||||
useLocalIp: true,
|
||||
allowedHosts: ["hubs.local"],
|
||||
headers: { "Access-Control-Allow-Origin": "hubs.local" },
|
||||
before: function(app) {
|
||||
// be flexible with people accessing via a local reticulum on another port
|
||||
app.use(cors({ origin: /hubs\.local(:\d*)?$/ }));
|
||||
// networked-aframe makes HEAD requests to the server for time syncing. Respond with an empty body.
|
||||
app.head("*", function(req, res, next) {
|
||||
if (req.method === "HEAD") {
|
||||
|
@ -178,7 +180,7 @@ module.exports = (env, argv) => ({
|
|||
new HTMLWebpackPlugin({
|
||||
filename: "index.html",
|
||||
template: path.join(__dirname, "src", "index.html"),
|
||||
chunks: ["vendor", "index"]
|
||||
chunks: ["vendor", "engine", "index"]
|
||||
}),
|
||||
new HTMLWebpackPlugin({
|
||||
filename: "hub.html",
|
||||
|
|
Загрузка…
Ссылка в новой задаче