From 0df2b9cae58bad8963e3d5deb85a788ccf282923 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Mon, 8 Jun 2026 14:50:07 -0400 Subject: [PATCH] feat(App, RealityOverridePage): add Reality Override page and integrate WebSocket message broadcasting --- src/app/App.tsx | 10 +- src/app/components/RealityOverridePage.tsx | 296 +++++++++++++++++++++ webdav3.py | 27 +- 3 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 src/app/components/RealityOverridePage.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index f6f1ebd..3341367 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,7 @@ import OtherPage from './components/OtherPage'; import ToolsPage from './components/ToolsPage'; import SearchOverlay from './components/SearchOverlay'; import MediaManager from './components/MediaManager'; +import RealityOverridePage from './components/RealityOverridePage'; import logoSvg from '../imports/logo.svg'; import { useSettings } from './settings'; @@ -35,7 +36,8 @@ type AppId = | 'charset-editor' | 'petscii-editor' | 'idle-animation' - | 'loading-animation'; + | 'loading-animation' + | 'reality-override'; export default function App() { const [currentPage, setCurrentPage] = useState('status'); @@ -105,6 +107,7 @@ export default function App() {
} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} /> } label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} /> + } label="Reality Override" onClick={() => setCurrentPage('reality-override')} />
@@ -140,8 +143,9 @@ export default function App() { 'sprite-editor': setCurrentPage('apps')} />, 'charset-editor': setCurrentPage('apps')} />, 'petscii-editor': setCurrentPage('apps')} />, - 'idle-animation': setCurrentPage('apps')} />, - 'loading-animation': setCurrentPage('apps')} /> + 'idle-animation': setCurrentPage('apps')} />, + 'loading-animation': setCurrentPage('apps')} />, + 'reality-override': setCurrentPage('apps')} /> }; // AppCard component for app grid diff --git a/src/app/components/RealityOverridePage.tsx b/src/app/components/RealityOverridePage.tsx new file mode 100644 index 0000000..9d825a3 --- /dev/null +++ b/src/app/components/RealityOverridePage.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-react'; + +// ── Rocket sprite (6w × 10h, nose pointing UP) ─────────────────────────────── +// Type: 0=hull, 1=trim, 2=porthole +const HULL: [number, number, 0 | 1 | 2][] = [ + [2,0,0],[3,0,0], + [1,1,0],[2,1,0],[3,1,0],[4,1,0], + [0,2,0],[1,2,0],[2,2,2],[3,2,2],[4,2,0],[5,2,0], + [0,3,0],[1,3,0],[2,3,0],[3,3,0],[4,3,0],[5,3,0], + [0,4,0],[1,4,0],[2,4,0],[3,4,0],[4,4,0],[5,4,0], + [1,5,0],[2,5,0],[3,5,0],[4,5,0], + [1,6,1],[2,6,0],[3,6,0],[4,6,1], +]; +// Alternating flame patterns +const FLAMES: [number, number][][] = [ + [[2,7],[3,7],[1,8],[2,8],[3,8],[4,8],[2,9],[3,9]], + [[2,7],[3,7],[2,8],[3,8],[1,9],[4,9]], +]; + +const S = 2; // sprite cell size in CSS pixels + +// ── Types ───────────────────────────────────────────────────────────────────── +interface Star { x:number; y:number; sz:number; ph:number; spd:number; col:[number,number,number]; } +interface Rocket { x:number; y:number; vx:number; vy:number; rot:number; color:string; ff:number; tick:number; } +interface Msg { text:string; id:number; } + +// ── Helpers ─────────────────────────────────────────────────────────────────── +const STAR_PALETTE: [number,number,number][] = [ + [255,255,255],[255,240,210],[210,220,255],[255,210,210],[200,255,230], +]; +const ROCKET_COLORS = ['#C0C0C0','#FF9090','#88AAFF','#AAFFAA','#FFDDAA','#FFAAFF']; + +function mkStar(W: number, H: number): Star { + return { + x: Math.random()*W|0, y: Math.random()*H|0, + sz: Math.random()<0.8 ? 1 : Math.random()<0.6 ? 2 : 3, + ph: Math.random()*Math.PI*2, + spd: Math.random()*0.04+0.005, + col: STAR_PALETTE[Math.random()*STAR_PALETTE.length|0], + }; +} + +function mkRocket(W: number, H: number, initialX?: number): Rocket { + const dir = Math.random()<0.5 ? 1 : -1; + const speed = Math.random()*1.5+0.8; + return { + x: initialX ?? (dir>0 ? -20 : W+20), + y: Math.random()*H*0.75+H*0.1, + vx: dir*speed, + vy: (Math.random()-0.5)*0.5, + rot: dir>0 ? Math.PI/2 : -Math.PI/2, + color: ROCKET_COLORS[Math.random()*ROCKET_COLORS.length|0], + ff: 0, tick: 0, + }; +} + +function buildNebula(W: number, H: number): HTMLCanvasElement { + const c = document.createElement('canvas'); + c.width = W; c.height = H; + const ctx = c.getContext('2d')!; + const blobs = [ + { cx:W*.25, cy:H*.35, sx:W*.2, sy:H*.2, r:80, g:0, b:170 }, + { cx:W*.72, cy:H*.22, sx:W*.16, sy:H*.2, r:0, g:40, b:155 }, + { cx:W*.55, cy:H*.65, sx:W*.22, sy:H*.2, r:65, g:0, b:115 }, + { cx:W*.12, cy:H*.75, sx:W*.14, sy:H*.15, r:0, g:55, b:90 }, + { cx:W*.88, cy:H*.6, sx:W*.13, sy:H*.15, r:90, g:15, b:70 }, + ]; + for (const { cx,cy,sx,sy,r,g,b } of blobs) { + for (let i = 0; i < 600; i++) { + const u = Math.random()+1e-9, v = Math.random(); + const z1 = Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v); + const z2 = Math.sqrt(-2*Math.log(u))*Math.sin(2*Math.PI*v); + const a = Math.min((0.04+Math.random()*0.12)/(1+(z1*z1+z2*z2)*0.12), 0.18); + if (a < 0.01) continue; + const sz = (Math.random()*4+2)|0; + ctx.fillStyle = `rgba(${r},${g},${b},${a})`; + ctx.fillRect((cx+z1*sx*.5)|0, (cy+z2*sy*.5)|0, sz, sz); + } + } + return c; +} + +// Pivot is sprite center (3*S, 5*S). rotate(±π/2) to aim left/right. +function drawRocket(ctx: CanvasRenderingContext2D, rk: Rocket) { + ctx.save(); + ctx.translate(rk.x, rk.y); + ctx.rotate(rk.rot); + ctx.translate(-3*S, -5*S); + for (const [dc, dr, t] of HULL) { + ctx.fillStyle = t===2 ? '#44DDFF' : t===1 ? '#555' : rk.color; + ctx.fillRect(dc*S, dr*S, S, S); + } + const flames = FLAMES[rk.ff%2]; + for (let i = 0; i < flames.length; i++) { + ctx.fillStyle = i%2===0 ? '#FF6600' : '#FFCC00'; + ctx.fillRect(flames[i][0]*S, flames[i][1]*S, S, S); + } + ctx.restore(); +} + +// ── Component ───────────────────────────────────────────────────────────────── +export default function RealityOverridePage({ onBack }: { onBack: () => void }) { + const canvasRef = useRef(null); + const animRef = useRef(0); + const starsRef = useRef([]); + const rocketsRef = useRef([]); + const nebulaRef = useRef(null); + const wsRef = useRef(null); + const msgIdRef = useRef(0); + + const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [currentCmd, setCurrentCmd] = useState(null); + const [history, setHistory] = useState([]); + const fadeTimer = useRef | undefined>(undefined); + + // ── Canvas animation loop ─────────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + let prevW = 0, prevH = 0; + + const loop = () => { + const W = canvas.clientWidth, H = canvas.clientHeight; + if (!W || !H) { animRef.current = requestAnimationFrame(loop); return; } + + if (W !== prevW || H !== prevH) { + canvas.width = W; canvas.height = H; + prevW = W; prevH = H; + nebulaRef.current = buildNebula(W, H); + starsRef.current = Array.from({ length: 200 }, () => mkStar(W, H)); + // Spread initial rockets across the screen + rocketsRef.current = [0,1,2].map(i => mkRocket(W, H, (i+0.5)*W/3)); + } + + const ctx = canvas.getContext('2d')!; + ctx.imageSmoothingEnabled = false; + + // Background + ctx.fillStyle = '#000008'; + ctx.fillRect(0, 0, W, H); + + // Nebula + if (nebulaRef.current) ctx.drawImage(nebulaRef.current, 0, 0); + + // Stars + for (const st of starsRef.current) { + st.ph += st.spd; + const br = Math.sin(st.ph)*0.4+0.6; + const [r,g,b] = st.col; + ctx.fillStyle = `rgba(${r},${g},${b},${br})`; + ctx.fillRect(st.x, st.y, st.sz, st.sz); + // Sparkle cross on bright large stars + if (st.sz >= 3 && br > 0.85) { + ctx.fillStyle = `rgba(${r},${g},${b},${br*0.35})`; + ctx.fillRect(st.x-2, st.y+1, st.sz+4, 1); + ctx.fillRect(st.x+1, st.y-2, 1, st.sz+4); + } + } + + // Rockets + const PAD = 60; + for (let i = rocketsRef.current.length-1; i >= 0; i--) { + const rk = rocketsRef.current[i]; + rk.x += rk.vx; rk.y += rk.vy; + rk.tick++; + if (rk.tick >= 8) { rk.tick = 0; rk.ff++; } + drawRocket(ctx, rk); + if (rk.x < -PAD || rk.x > W+PAD || rk.y < -PAD || rk.y > H+PAD) { + rocketsRef.current.splice(i, 1); + setTimeout(() => { + const cv = canvasRef.current; + if (cv) rocketsRef.current.push(mkRocket(cv.clientWidth, cv.clientHeight)); + }, Math.random()*4000+500); + } + } + + animRef.current = requestAnimationFrame(loop); + }; + + animRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(animRef.current); + }, []); + + // ── WebSocket ─────────────────────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + let ws: WebSocket | null = null; + + const connect = () => { + if (cancelled) return; + ws = new WebSocket(`ws://${window.location.hostname}/ws`); + wsRef.current = ws; + setWsStatus('connecting'); + ws.onopen = () => { if (!cancelled) setWsStatus('connected'); }; + ws.onmessage = (e) => { + if (cancelled) return; + const id = msgIdRef.current++; + const msg: Msg = { text: String(e.data), id }; + setCurrentCmd(msg); + setHistory(prev => [...prev.slice(-9), msg]); + clearTimeout(fadeTimer.current); + fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000); + }; + ws.onclose = () => { + if (cancelled) return; + setWsStatus('disconnected'); + setTimeout(connect, 3000); + }; + ws.onerror = () => ws?.close(); + }; + + connect(); + return () => { cancelled = true; ws?.close(); }; + }, []); + + return ( +
+ + + {/* Back */} + + + {/* WS status */} +
+ {wsStatus === 'connecting' && <>Connecting…} + {wsStatus === 'connected' && <>Connected} + {wsStatus === 'disconnected' && <>Reconnecting…} +
+ + {/* CSS keyframe for command flash */} + + + {/* Idle title — shown only before first message */} + {history.length === 0 && ( +
+
+ Reality Override +
+
+ Awaiting Commands_ +
+
+ )} + + {/* Centered current command */} + {currentCmd && ( +
+
+ {currentCmd.text} +
+
+ )} + + {/* History log */} + {history.length > 0 && ( +
+
+ {history.map(m => ( +
+ >{m.text} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/webdav3.py b/webdav3.py index f0b390b..65af666 100644 --- a/webdav3.py +++ b/webdav3.py @@ -43,7 +43,10 @@ from http.server import HTTPServer, BaseHTTPRequestHandler from socketserver import ThreadingMixIn import urllib.request, urllib.parse, urllib.error, urllib.parse from time import timezone, strftime, localtime, gmtime -import os, sys, re, shutil, struct, uuid, hashlib, mimetypes, base64, socket +import os, sys, re, shutil, struct, threading, uuid, hashlib, mimetypes, base64, socket + +_ws_lock = threading.Lock() +_ws_clients: set = set() # connected WebSocket sockets # Debug message ( True / False ) sys_debug = False @@ -697,9 +700,18 @@ class DAVRequestHandler(BaseHTTPRequestHandler): return data def ws_process_message(self, message: str) -> str: - """Override this to handle incoming WebSocket messages. Return the response string.""" + """Override this to handle incoming WebSocket messages. Return the response to broadcast.""" return message # echo by default + def _ws_broadcast(self, payload: bytes): + with _ws_lock: + clients = set(_ws_clients) + for sock in clients: + try: + self._ws_send(sock, payload) + except Exception: + pass + def _ws_send(self, sock, payload: bytes): length = len(payload) if length < 126: @@ -721,9 +733,11 @@ class DAVRequestHandler(BaseHTTPRequestHandler): self.send_header('Sec-WebSocket-Accept', accept) self.end_headers() client = f'{self.client_address[0]}:{self.client_address[1]}' - print(f'[WS] {client} connected') sock = self.connection sock.settimeout(None) + with _ws_lock: + _ws_clients.add(sock) + print(f'[WS] {client} connected (total: {len(_ws_clients)})') try: while True: b0, b1 = self._ws_recv(sock, 2) @@ -749,10 +763,13 @@ class DAVRequestHandler(BaseHTTPRequestHandler): print(f'[WS] {client} recv ({length}b): {message}') response = self.ws_process_message(message) if response is not None: - self._ws_send(sock, response.encode('utf-8')) + self._ws_broadcast(response.encode('utf-8')) except Exception: pass - print(f'[WS] {client} disconnected') + finally: + with _ws_lock: + _ws_clients.discard(sock) + print(f'[WS] {client} disconnected (total: {len(_ws_clients)})') def do_GET(self, onlyhead=False): if (self.path == '/ws'