[object Object]

← back to Dead Agentabrams

Music room: adjustable movie screen (drag+scale) + optional draggable orb (Steve)

a7cda269862f9c88a5afbb67fe2b00ec267a1ad5 · 2026-09-03 12:37:38 -0700 · Steve Abrams

- Movie screen is now draggable to reposition and corner-resizable to scale up/down
  (--movie-dx/-dy/-scale, persisted to localStorage); gold corner handle + move overlay.
- Orb is OFF on load ('onload no orb') but a new 🔮 Orb toggle turns it on, and once on it
  can be DRAGGED around the room (click = AI VJ, drag = move; position persisted). The WebGL
  loop skips all work while the orb is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a7cda269862f9c88a5afbb67fe2b00ec267a1ad5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 12:37:38 2026 -0700

    Music room: adjustable movie screen (drag+scale) + optional draggable orb (Steve)
    
    - Movie screen is now draggable to reposition and corner-resizable to scale up/down
      (--movie-dx/-dy/-scale, persisted to localStorage); gold corner handle + move overlay.
    - Orb is OFF on load ('onload no orb') but a new 🔮 Orb toggle turns it on, and once on it
      can be DRAGGED around the room (click = AI VJ, drag = move; position persisted). The WebGL
      loop skips all work while the orb is off.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 room/index.html | 97 ++++++++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 83 insertions(+), 14 deletions(-)

diff --git a/room/index.html b/room/index.html
index 55bc4cd..dfe5441 100644
--- a/room/index.html
+++ b/room/index.html
@@ -24,6 +24,7 @@
   #stage{position:fixed;inset:0;display:block}
   canvas{position:absolute;inset:0;width:100%;height:100%}
   #orb{z-index:4;pointer-events:none}
+  #stage:not(.orb-on) #orb{display:none}   /* orb OFF by default (Steve: onload no orb); 🔮 Orb toggles it on */
   /* ---- Concert-video backdrop (TK-11178): official GD footage behind the generative scene.
      Confined to a positioned box (Steve: "the area in back of where singers would sing") —
      defaults roughly over the stage riser, fully dialable via the Videos panel's X/Y/W/H
@@ -48,15 +49,24 @@
      #venueVideo). Shown ONLY while a video is playing (.movie-on). ---- */
   #stage.movie-on #venueVideo{
     opacity:1; z-index:1;
-    left:50%; top:33%; transform:translate(-50%,-50%);
-    width:min(54vw,760px); height:auto; aspect-ratio:16/9;
+    left:calc(50% + var(--movie-dx, 0px)); top:calc(33% + var(--movie-dy, 0px));
+    transform:translate(-50%,-50%);
+    width:calc(min(54vw,760px) * var(--movie-scale, 1)); height:auto; aspect-ratio:16/9;
     background:#000; border-radius:4px; border:2px solid #0a0304;
     box-shadow:0 0 0 8px #17070a, 0 0 0 10px rgba(242,182,74,.45),
                0 30px 90px rgba(0,0,0,.75), 0 0 70px rgba(242,182,74,.18);
   }
   #stage.movie-on #sky{opacity:.5}
   #stage.movie-on #scene{opacity:1}
-  @media (max-width:820px){ #stage.movie-on #venueVideo{width:86vw;top:30%} }
+  @media (max-width:820px){ #stage.movie-on #venueVideo{width:calc(86vw * var(--movie-scale,1));top:30%} }
+  /* drag-to-move + corner-resize handles on the movie screen (Steve: adjust + scale up/down) */
+  #venueVideo .mv-move,#venueVideo .mv-size{position:absolute;pointer-events:auto;display:none}
+  #stage.movie-on #venueVideo .mv-move{display:block;inset:0;cursor:move;z-index:1}
+  #stage.movie-on #venueVideo .mv-size{display:block;right:-9px;bottom:-9px;width:26px;height:26px;z-index:2;
+    cursor:nwse-resize;border-radius:5px;background:rgba(242,182,74,.9);
+    box-shadow:0 2px 10px rgba(0,0,0,.55);border:1px solid #17070a}
+  #stage.movie-on #venueVideo .mv-size::after{content:"⤡";position:absolute;inset:0;display:flex;
+    align-items:center;justify-content:center;color:#17070a;font-size:14px;font-weight:700}
   .btn:disabled,button:disabled{opacity:.42;cursor:not-allowed}
   /* ---- Controls in a collapsed RIGHT drawer, opened by the top-right hamburger (TK-11178 UI) ---- */
   #dock{
@@ -353,7 +363,7 @@
   <h1 class="sr">Scarlet Riverboat Masquerade — an animated generative gallery</h1>
   <!-- role removed from <main>: role="application" suppressed normal screen-reader navigation over the native controls + prose -->
 
-  <div id="venueVideo" aria-hidden="true"><div id="ytHost"></div></div>
+  <div id="venueVideo" aria-hidden="true"><div id="ytHost"></div><div class="mv-move" title="Drag to move the screen"></div><div class="mv-size" title="Drag to scale the screen"></div></div>
   <canvas id="sky" aria-hidden="true"></canvas>
   <canvas id="scene" aria-hidden="true"></canvas>
   <canvas id="orb" aria-hidden="true"></canvas>
@@ -432,6 +442,7 @@
         <button class="btn" id="reactBtn" aria-pressed="false">♫ React to Music</button>
         <button class="btn" id="pteroBtn">🦖 Fly Over</button>
         <button class="btn" id="venueBtn" aria-pressed="true">🏛 Venue</button>
+        <button class="btn" id="orbBtn" aria-pressed="false" title="Show the orb — then drag it around the room">🔮 Orb</button>
       </div>
     </div>
     <div class="group">
@@ -1598,6 +1609,15 @@ venueBtn.addEventListener('click',()=>{
   announce(venueOn?'Concert-video backdrop on.':'Concert-video backdrop off.');
 });
 
+// Orb on/off (Steve: "allow user to turn on the orb"). Off on load; drag it to move it around.
+const orbBtn=document.getElementById('orbBtn');
+if(orbBtn) orbBtn.addEventListener('click',()=>{
+  window.__orbOn=!window.__orbOn;
+  orbBtn.setAttribute('aria-pressed',String(window.__orbOn));
+  document.getElementById('stage').classList.toggle('orb-on', window.__orbOn);
+  announce(window.__orbOn?'Orb on — drag it to move it around the room.':'Orb off.');
+});
+
 // keyboard shortcuts
 window.addEventListener('keydown',e=>{
   const t=e.target;
@@ -1733,6 +1753,34 @@ const MR_VIDEOS=[
   });
 })();
 
+// ---- Movie screen: drag anywhere to move, drag the gold corner to scale up/down (Steve). Persisted. ----
+(function(){
+  const stage=document.getElementById('stage'), vv=document.getElementById('venueVideo');
+  if(!stage||!vv) return;
+  const root=document.documentElement.style, KEY='deadroom.movie.v1';
+  const st=Object.assign({dx:0,dy:0,scale:1}, (function(){try{return JSON.parse(localStorage.getItem(KEY))||{};}catch(e){return {};}})());
+  const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
+  function apply(){ root.setProperty('--movie-dx',st.dx+'px'); root.setProperty('--movie-dy',st.dy+'px'); root.setProperty('--movie-scale',String(st.scale)); }
+  function save(){ try{ localStorage.setItem(KEY,JSON.stringify(st)); }catch(e){} }
+  apply();
+  const mover=vv.querySelector('.mv-move');
+  if(mover) mover.addEventListener('pointerdown',e=>{
+    e.preventDefault(); try{mover.setPointerCapture(e.pointerId);}catch(_){}
+    const sx=e.clientX, sy=e.clientY, ox=st.dx, oy=st.dy;
+    const mv=ev=>{ st.dx=clamp(ox+(ev.clientX-sx),-innerWidth*0.45,innerWidth*0.45); st.dy=clamp(oy+(ev.clientY-sy),-innerHeight*0.30,innerHeight*0.60); apply(); };
+    const up=()=>{ mover.removeEventListener('pointermove',mv); mover.removeEventListener('pointerup',up); save(); };
+    mover.addEventListener('pointermove',mv); mover.addEventListener('pointerup',up);
+  });
+  const sizer=vv.querySelector('.mv-size');
+  if(sizer) sizer.addEventListener('pointerdown',e=>{
+    e.preventDefault(); e.stopPropagation(); try{sizer.setPointerCapture(e.pointerId);}catch(_){}
+    const sx=e.clientX, os=st.scale;
+    const mv=ev=>{ st.scale=clamp(os+(ev.clientX-sx)/400,0.35,3.2); apply(); };
+    const up=()=>{ sizer.removeEventListener('pointermove',mv); sizer.removeEventListener('pointerup',up); save(); };
+    sizer.addEventListener('pointermove',mv); sizer.addEventListener('pointerup',up);
+  });
+})();
+
 function _yearOf(s){
   const m=String((s&&s.date)||'').match(/\b(19\d{2})\b/); if(m) return +m[1];
   const m2=String((s&&s.id)||'').match(/gd(\d{2,4})/i);
@@ -2349,9 +2397,11 @@ if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
 <script>
 (function(){
   if(!window.THREE) return;
-  // Steve: "remove the orb. onload no orb." — the WebGL AI-VJ orb is disabled; the concert
-  // now plays on a movie screen on the stage instead. Flip ORB_ENABLED to restore it.
-  const ORB_ENABLED=false; if(!ORB_ENABLED) return;
+  // Steve: "onload no orb" + "allow user to turn on the orb" + "allow orb to move around the
+  // room". The orb is OFF by default (window.__orbOn=false, #stage lacks .orb-on so #orb is
+  // display:none) and the loop skips rendering; the 🔮 Orb toggle turns it on, and the orb can
+  // be dragged around the room.
+  if(window.__orbOn===undefined) window.__orbOn=false;
   const canvas=document.getElementById('orb'); if(!canvas) return;
   let W=innerWidth,H=innerHeight;
   const renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});
@@ -2359,6 +2409,8 @@ if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
   const scene=new THREE.Scene();
   const camera=new THREE.PerspectiveCamera(50,W/H,0.1,100); camera.position.z=5;
   const group=new THREE.Group(); group.position.y=1.45; scene.add(group);   // floats up in the sky over the boat
+  // restore a dragged-around orb position (Steve: "allow orb to move around the room")
+  try{ const op=JSON.parse(localStorage.getItem('deadroom.orbpos.v1')); if(op){ group.position.x=op.x||0; group.position.y=(op.y!=null?op.y:1.45); } }catch(_){}
 
   // ---- the core orb (its own sub-group so effects can rotate it WITHOUT tilting the world-down beams) ----
   const orb=new THREE.Group(); group.add(orb);
@@ -2439,22 +2491,39 @@ if(_p.endsWith('/room') || /dead\.agentabrams/i.test(location.hostname)){
     }
   }
 
-  // click the orb to hand it to the AI VJ (autonomous, reads the live audio and choreographs itself)
+  // DRAG the orb to move it around the room; a click (no drag) hands it to the AI VJ.
   function orbScreen(){ const v=group.position.clone().project(camera); return {x:(v.x*0.5+0.5)*W, y:(-v.y*0.5+0.5)*H, r:Math.max(60,Math.min(W,H)*0.16)}; }
   addEventListener('pointerdown', e=>{
+    if(!window.__orbOn) return;                                  // orb off → ignore
     const o=orbScreen();
-    if(Math.hypot(e.clientX-o.x, e.clientY-o.y) < o.r){
-      ai=!ai; e.stopPropagation();
-      if(ai){ enterFx('celestial',9); if(window.SRM) window.SRM.aiVJ=true; }
-      else { fx='none'; if(window.SRM) window.SRM.aiVJ=false; }
-      const lv=document.getElementById('live'); if(lv) lv.textContent = ai ? 'The orb is taken over by the AI VJ — it choreographs UFOs, celestial patterns and blooms to the music.' : 'AI VJ released. The orb responds to song changes on its own.';
-    }
+    if(Math.hypot(e.clientX-o.x, e.clientY-o.y) >= o.r) return;  // not on the orb
+    e.stopPropagation();
+    const sx=e.clientX, sy=e.clientY, px=group.position.x, py=group.position.y, k=(2*5*Math.tan(50*Math.PI/360))/H; // world-units per screen px
+    let moved=false;
+    const mv=ev=>{
+      if(Math.hypot(ev.clientX-sx, ev.clientY-sy)>4) moved=true;
+      group.position.x=Math.max(-3.4, Math.min(3.4, px+(ev.clientX-sx)*k));
+      group.position.y=Math.max(-1.2, Math.min(3.4, py-(ev.clientY-sy)*k));
+    };
+    const up=()=>{
+      removeEventListener('pointermove',mv,true); removeEventListener('pointerup',up,true);
+      if(!moved){                                               // a click → toggle AI VJ
+        ai=!ai;
+        if(ai){ enterFx('celestial',9); if(window.SRM) window.SRM.aiVJ=true; }
+        else { fx='none'; if(window.SRM) window.SRM.aiVJ=false; }
+        const lv=document.getElementById('live'); if(lv) lv.textContent = ai ? 'The orb is taken over by the AI VJ — it choreographs UFOs, celestial patterns and blooms to the music.' : 'AI VJ released. The orb responds to song changes on its own.';
+      } else {                                                  // a drag → remember where the orb was moved
+        try{ localStorage.setItem('deadroom.orbpos.v1', JSON.stringify({x:group.position.x,y:group.position.y})); }catch(_){}
+      }
+    };
+    addEventListener('pointermove',mv,true); addEventListener('pointerup',up,true);
   }, true);
 
   const easeOut=x=>1-Math.pow(1-x,3);
   let last=performance.now();
   let _orbStatic=false;
   function loop(now){ requestAnimationFrame(loop);
+    if(!window.__orbOn){ return; }   // orb off (default) → skip all render work
     const dt=Math.min(0.05,((now||performance.now())-last)/1000); last=now||performance.now();
     const s=window.SRM||{}, t=(now||performance.now())/1000;
     // Honor reduced motion (WCAG 2.3.3): draw one calm static frame, then stop spinning + re-rendering.

← 650582d Music room: concert plays on a movie SCREEN on the stage, or  ·  back to Dead Agentabrams  ·  Music room: mobile-friendly pass — shrink + fluid-scale text 8769b6f →