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;
 };