File size: 6,299 Bytes
bf5738f
 
 
 
 
 
 
 
 
 
35fe290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf5738f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35fe290
 
 
 
 
 
 
 
 
 
 
 
 
 
bf5738f
35fe290
 
 
 
 
 
 
bf5738f
 
35fe290
 
bf5738f
 
 
 
 
 
35fe290
bf5738f
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
export function connectSocketPlayer(parent, session, onState) {
  const canvas = document.createElement('canvas');
  canvas.width = 1920; canvas.height = 1080;
  canvas.tabIndex = 0;
  canvas.setAttribute('aria-label', 'Live Unreal scene. Click to control, WASD to move, E to interact.');
  Object.assign(canvas.style, {width:'100%',height:'100%',objectFit:'contain',display:'block',outline:'none'});
  parent.append(canvas);
  const context = canvas.getContext('2d', {alpha:false});
  const ws = new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/api/stream/${session.id}?token=${encodeURIComponent(session.token)}`);
  ws.binaryType = 'arraybuffer';
  let closed=false, active=false, live=false, locked=false, decoding=Promise.resolve(), decoder=null;
  const pendingFrames=new Map();
  const render=(frame,sequence)=>{
    if(closed)return;
    const w=frame.displayWidth||frame.width,h=frame.displayHeight||frame.height;
    if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
    context.drawImage(frame,0,0);canvas.dataset.frames=String(sequence);
    if(!live){live=true;onState('live');}
    if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({ack:sequence}));
  };
  ws.onopen=async()=>{
    let supported=false;
    if('VideoDecoder' in window){try{supported=(await VideoDecoder.isConfigSupported({codec:'avc1.42C028',optimizeForLatency:true})).supported;}catch{}}
    if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({video:supported?'h264':'jpeg'}));
  };
  const keys = new Map([['KeyW',87],['KeyA',65],['KeyS',83],['KeyD',68],['KeyE',69],['Space',32],['ShiftLeft',16],['ShiftRight',16]]);
  const pressed = new Set();
  const send = data => { if(!closed && ws.readyState===WebSocket.OPEN) ws.send(data); };
  const release = () => { for(const key of pressed) send(new Uint8Array([61,key])); pressed.clear(); };
  const leave = () => { release(); send(new Uint8Array([71])); };
  const fallback = () => { if(closed)return; active=true; onState('Move the mouse over the scene to look. Esc releases control.'); };
  const lockChange = () => { const next=document.pointerLockElement===canvas; if(locked && !next){active=false;leave();} locked=next; };
  const blur = () => { active=false;leave(); };
  document.addEventListener('pointerlockerror', fallback);
  document.addEventListener('pointerlockchange', lockChange);
  window.addEventListener('blur', blur);
  canvas.onblur = blur;
  canvas.onclick = async () => {
    if(!live)return;
    canvas.focus();active=true;send(new Uint8Array([70]));
    if(document.pointerLockElement!==canvas){try{await canvas.requestPointerLock();}catch{fallback();}}
  };
  canvas.onkeydown = event => {
    if(event.code==='Escape'){active=false;leave();if(locked)document.exitPointerLock();return;}
    const key=keys.get(event.code);if(!active||!key)return;
    event.preventDefault();pressed.add(key);send(new Uint8Array([60,key,event.repeat?1:0]));
  };
  canvas.onkeyup = event => {const key=keys.get(event.code);if(key){event.preventDefault();pressed.delete(key);send(new Uint8Array([61,key]));}};
  canvas.onmouseleave = () => {if(!locked)leave();};
  canvas.onmouseenter = () => {if(active)send(new Uint8Array([70]));};
  function coordinates(event){
    const r=canvas.getBoundingClientRect(), scale=Math.min(r.width/canvas.width,r.height/canvas.height);
    const w=canvas.width*scale,h=canvas.height*scale;
    return {x:locked?32768:Math.max(0,Math.min(65535,(event.clientX-r.left-(r.width-w)/2)/w*65535)),
      y:locked?32768:Math.max(0,Math.min(65535,(event.clientY-r.top-(r.height-h)/2)/h*65535)),w,h};
  }
  canvas.onmousemove = event => {
    if(!active)return;
    const c=coordinates(event), b=new ArrayBuffer(9),v=new DataView(b);
    v.setUint8(0,74);v.setUint16(1,c.x,true);v.setUint16(3,c.y,true);
    v.setInt16(5,Math.max(-32767,Math.min(32767,event.movementX/c.w*65534)),true);
    v.setInt16(7,Math.max(-32767,Math.min(32767,event.movementY/c.h*65534)),true);send(b);
  };
  for(const [eventName,type] of [['onmousedown',72],['onmouseup',73]]) canvas[eventName]=event=>{
    if(!active||event.button>2)return;event.preventDefault();
    const c=coordinates(event),b=new ArrayBuffer(6),v=new DataView(b);
    v.setUint8(0,type);v.setUint8(1,event.button);v.setUint16(2,c.x,true);v.setUint16(4,c.y,true);send(b);
  };
  canvas.oncontextmenu=event=>event.preventDefault();
  ws.onmessage = event => {
    if(typeof event.data==='string'){
      const config=JSON.parse(event.data);
      if(config.type==='codec'){
        canvas.dataset.codec=config.codec;
        if(config.codec!=='jpeg'){
          decoder=new VideoDecoder({output:frame=>{
            const sequence=pendingFrames.get(frame.timestamp);pendingFrames.delete(frame.timestamp);
            if(sequence!==undefined)render(frame,sequence);frame.close();
          },error:()=>{if(!closed){onState('disconnected');ws.close();}}});
          decoder.configure({codec:config.codec,codedWidth:config.width,codedHeight:config.height,optimizeForLatency:true});
        }
      }
      return;
    }
    if(!(event.data instanceof ArrayBuffer))return;
    const header=new DataView(event.data),sequence=header.getUint32(0),kind=header.getUint8(4),timestamp=Number(header.getBigUint64(5));
    if(kind!==0){
      pendingFrames.set(timestamp,sequence);
      try{decoder.decode(new EncodedVideoChunk({type:kind===1?'key':'delta',timestamp,data:event.data.slice(13)}));}
      catch{onState('disconnected');ws.close();}
      return;
    }
    decoding=decoding.then(async()=>{
      if(closed)return;
      const bitmap=await createImageBitmap(new Blob([event.data.slice(13)],{type:'image/jpeg'}));
      render(bitmap,sequence);bitmap.close();
    }).catch(()=>{if(!closed){onState('disconnected');ws.close();}});
  };
  ws.onclose=()=>{if(!closed)onState('disconnected');};
  ws.onerror=()=>{if(!closed)onState('disconnected');};
  return {close(){
    leave();closed=true;if(locked)document.exitPointerLock();ws.close();
    if(decoder && decoder.state!=='closed')decoder.close();pendingFrames.clear();
    document.removeEventListener('pointerlockerror',fallback);document.removeEventListener('pointerlockchange',lockChange);window.removeEventListener('blur',blur);
    parent.replaceChildren();
  }};
}