diff --git a/src/components/image-plus.js b/src/components/image-plus.js
index 6f0163d23bb52ac1974077573d99f2408ab72640..a7efa5508fdce28031d1f1171e7c5b19b5944538 100644
--- a/src/components/image-plus.js
+++ b/src/components/image-plus.js
@@ -32,8 +32,40 @@ class GIFTexture extends THREE.Texture {
   }
 }
 
+/**
+ * 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;
+}
+
+const textureLoader = new THREE.TextureLoader();
+textureLoader.setCrossOrigin("anonymous");
+
+const textureCache = new Map();
+
+const noop = function() {};
+
+const errorImage = new Image();
+errorImage.src =
+  "data:image/png;base64,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";
+const errorTexture = new THREE.Texture(errorImage);
+errorImage.onload = () => {
+  errorTexture.needsUpdate = true;
+};
+
 AFRAME.registerComponent("image-plus", {
-  dependencies: ["geometry", "material"],
+  dependencies: ["geometry"],
 
   schema: {
     src: { type: "string" },
@@ -68,15 +100,6 @@ AFRAME.registerComponent("image-plus", {
     });
   },
 
-  _onMaterialLoaded(e) {
-    const src = e.detail.src;
-    const w = src.videoWidth || src.width;
-    const h = src.videoHeight || src.height;
-    if (w || h) {
-      this._fit(w, h);
-    }
-  },
-
   _onGrab: (function() {
     const q = new THREE.Quaternion();
     return function() {
@@ -88,13 +111,15 @@ AFRAME.registerComponent("image-plus", {
   })(),
 
   init() {
-    this._onMaterialLoaded = this._onMaterialLoaded.bind(this);
     this._onGrab = this._onGrab.bind(this);
 
-    this.el.addEventListener("materialtextureloaded", this._onMaterialLoaded);
-    this.el.addEventListener("materialvideoloadeddata", this._onMaterialLoaded);
     this.el.addEventListener("grab-start", this._onGrab);
 
+    const material = new THREE.MeshBasicMaterial();
+    material.side = THREE.DoubleSide;
+    material.transparent = true;
+    this.el.getObject3D("mesh").material = material;
+
     const worldPos = new THREE.Vector3().copy(this.data.initialOffset);
     this.billboardTarget = document.querySelector("#player-camera").object3D;
     this.billboardTarget.localToWorld(worldPos);
@@ -102,54 +127,151 @@ AFRAME.registerComponent("image-plus", {
     this.billboardTarget.getWorldQuaternion(this.el.object3D.quaternion);
   },
 
-  async loadGIF(url) {
-    const worker = new GIFWorker();
-    worker.onmessage = e => {
-      const [success, frames, delays, disposals] = e.data;
-      if (!success) {
-        console.error("error loading gif", e.data[1]);
-        return;
+  remove() {
+    const material = this.el.getObject3D("mesh").material;
+    const texture = material.map;
+
+    if (texture === errorTexture) return;
+
+    const url = texture.image.src;
+    const cacheItem = textureCache.get(url);
+    cacheItem.count--;
+    if (cacheItem.count <= 0) {
+      console.log("removing from cache");
+      // Unload the video element to prevent it from continuing to play in the background
+      if (texture.image instanceof HTMLVideoElement) {
+        const video = texture.image;
+        video.pause();
+        video.src = "";
+        video.load();
       }
 
-      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 material = this.el.components.material.material;
-            material.map = new GIFTexture(frames, delays, disposals);
-            material.needsUpdate = true;
-            this._fit(frames[0].width, frames[0].height);
-          }
-        };
-        img.src = frames[i];
+      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);
+    }
+  },
+
+  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);
+    });
+  },
+
+  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));
+      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;
       }
-    };
-    const rawImageData = await fetch(url, { mode: "cors" }).then(r => r.arrayBuffer());
-    worker.postMessage(rawImageData, [rawImageData]);
+    });
+  },
+
+  loadImage(url) {
+    return new Promise((resolve, reject) => {
+      textureLoader.load(url, resolve, noop, function(xhr) {
+        reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`);
+      });
+    });
   },
 
   async update() {
-    const mediaJson = await fetch("https://smoke-dev.reticulum.io/api/v1/media", {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json"
-      },
-      body: JSON.stringify({
-        media: {
-          url: this.data.src
+    let texture;
+    try {
+      const mediaJson = await fetch("https://smoke-dev.reticulum.io/api/v1/media", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json"
+        },
+        body: JSON.stringify({
+          media: {
+            url: this.data.src
+          }
+        })
+      }).then(r => r.json());
+      const url = mediaJson.images.raw;
+
+      if (textureCache.has(url)) {
+        const cacheItem = textureCache.get(url);
+        texture = cacheItem.texture;
+        cacheItem.count++;
+      } else {
+        const contentType = await fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
+        if (contentType === "image/gif") {
+          console.log("load gif", contentType);
+          texture = await this.loadGIF(url);
+        } else if (contentType.startsWith("image/")) {
+          console.log("load image", contentType);
+          texture = await this.loadImage(url);
+        } else if (contentType.startsWith("video")) {
+          console.log("load video", contentType);
+          texture = await this.loadVideo(url);
+        } else {
+          throw new Error(`Unknown centent type: ${contentType}`);
         }
-      })
-    }).then(r => r.json());
-    const imageUrl = mediaJson.images.raw;
-    const contentType = await fetch(imageUrl, { method: "HEAD" }).then(r => r.headers.get("content-type"));
-    if (contentType === "image/gif") {
-      return this.loadGIF(imageUrl);
-    } else {
-      this.el.setAttribute("material", "src", `url(${imageUrl})`);
-      return Promise.resolve();
+
+        textureCache.set(url, { count: 1, texture });
+      }
+    } catch (e) {
+      console.error("Error loading media", this.data.src, e);
+      texture = errorTexture;
     }
+
+    const material = this.el.getObject3D("mesh").material;
+    material.map = texture;
+    material.needsUpdate = true;
+    this._fit(texture.image.videoWidth || texture.image.width, texture.image.videoHeight || texture.image.height);
   }
 });
diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js
index 9e9d8bff32b729edc341d8d7fb424abbfc7e166f..084004b8831c2626e0a8fd278c11767dc5008c62 100644
--- a/src/components/sticky-object.js
+++ b/src/components/sticky-object.js
@@ -12,11 +12,20 @@ AFRAME.registerComponent("sticky-object", {
     this._onGrab = this._onGrab.bind(this);
     this._onRelease = this._onRelease.bind(this);
     this._onBodyLoaded = this._onBodyLoaded.bind(this);
+  },
+
+  play() {
     this.el.addEventListener("grab-start", this._onGrab);
     this.el.addEventListener("grab-end", this._onRelease);
     this.el.addEventListener("body-loaded", this._onBodyLoaded);
   },
 
+  pause() {
+    this.el.removeEventListener("grab-start", this._onGrab);
+    this.el.removeEventListener("grab-end", this._onRelease);
+    this.el.removeEventListener("body-loaded", this._onBodyLoaded);
+  },
+
   setLocked(locked) {
     if (!NAF.utils.isMine(this.el)) return;
 
diff --git a/src/hub.html b/src/hub.html
index 1581794db7e7a26007a0ce9ee1c99daae1452835..a75e5fa915dd613e33c9a3f50be78f248326732b 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -202,7 +202,6 @@
                     stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
                     geometry="primitive: plane"
-                    material="shader: flat; side: double; transparent: true;"
                     image-plus
                     sticky-object="autoLockOnLoad: true; autoLockOnRelease: true;"
                     position-at-box-shape-border="target:.delete-button;dirs:forward,back"