• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (C) 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17'use strict';
18
19function ConnectToDevice(device_id) {
20  console.log('ConnectToDevice ', device_id);
21  const keyboardCaptureCtrl = document.getElementById('keyboard-capture-control');
22  createToggleControl(keyboardCaptureCtrl, "keyboard", onKeyboardCaptureToggle);
23  const micCaptureCtrl = document.getElementById('mic-capture-control');
24  createToggleControl(micCaptureCtrl, "mic", onMicCaptureToggle);
25
26  const deviceScreen = document.getElementById('device-screen');
27  const deviceAudio = document.getElementById('device-audio');
28  const statusMessage = document.getElementById('status-message');
29
30  let connectionAttemptDuration = 0;
31  const intervalMs = 500;
32  let deviceStatusEllipsisCount = 0;
33  let animateDeviceStatusMessage = setInterval(function() {
34    connectionAttemptDuration += intervalMs;
35    if (connectionAttemptDuration > 30000) {
36      statusMessage.className = 'error';
37      statusMessage.textContent = 'Connection should have occurred by now. ' +
38          'Please attempt to restart the guest device.';
39    } else {
40      if (connectionAttemptDuration > 15000) {
41        statusMessage.textContent = 'Connection is taking longer than expected';
42      } else {
43        statusMessage.textContent = 'Connecting to device';
44      }
45      deviceStatusEllipsisCount = (deviceStatusEllipsisCount + 1) % 4;
46      statusMessage.textContent += '.'.repeat(deviceStatusEllipsisCount);
47    }
48  }, intervalMs);
49
50  deviceScreen.addEventListener('loadeddata', (evt) => {
51    clearInterval(animateDeviceStatusMessage);
52    statusMessage.textContent = 'Awaiting adb connection...';
53    resizeDeviceView();
54    deviceScreen.style.visibility = 'visible';
55    // Enable the buttons after the screen is visible.
56    for (const [_, button] of Object.entries(buttons)) {
57      if (!button.adb) {
58        button.button.disabled = false;
59      }
60    }
61    // Start the adb connection if it is not already started.
62    initializeAdb();
63  });
64
65  let videoStream;
66  let display_label;
67  let buttons = {};
68  let mouseIsDown = false;
69  let deviceConnection;
70  let touchIdSlotMap = new Map();
71  let touchSlots = new Array();
72  let deviceStateLidSwitchOpen = null;
73  let deviceStateHingeAngleValue = null;
74
75  function showAdbConnected() {
76    // Screen changed messages are not reported until after boot has completed.
77    // Certain default adb buttons change screen state, so wait for boot
78    // completion before enabling these buttons.
79    statusMessage.className = 'connected';
80    statusMessage.textContent =
81        'adb connection established successfully.';
82    setTimeout(function() {
83      statusMessage.style.visibility = 'hidden';
84    }, 5000);
85    for (const [_, button] of Object.entries(buttons)) {
86      if (button.adb) {
87        button.button.disabled = false;
88      }
89    }
90  }
91
92  function initializeAdb() {
93    init_adb(
94        deviceConnection,
95        showAdbConnected,
96        function() {
97          statusMessage.className = 'error';
98          statusMessage.textContent = 'adb connection failed.';
99          statusMessage.style.visibility = 'visible';
100          for (const [_, button] of Object.entries(buttons)) {
101            if (button.adb) {
102              button.button.disabled = true;
103            }
104          }
105        });
106  }
107
108  let currentRotation = 0;
109  let currentDisplayDetails;
110  function onControlMessage(message) {
111    let message_data = JSON.parse(message.data);
112    console.log(message_data)
113    let metadata = message_data.metadata;
114    if (message_data.event == 'VIRTUAL_DEVICE_BOOT_STARTED') {
115      // Start the adb connection after receiving the BOOT_STARTED message.
116      // (This is after the adbd start message. Attempting to connect
117      // immediately after adbd starts causes issues.)
118      initializeAdb();
119    }
120    if (message_data.event == 'VIRTUAL_DEVICE_SCREEN_CHANGED') {
121      if (metadata.rotation != currentRotation) {
122        // Animate the screen rotation.
123        deviceScreen.style.transition = 'transform 1s';
124      } else {
125        // Don't animate screen resizes, since these appear as odd sliding
126        // animations if the screen is rotated due to the translateY.
127        deviceScreen.style.transition = '';
128      }
129
130      currentRotation = metadata.rotation;
131      updateDeviceDisplayDetails({
132        dpi: metadata.dpi,
133        x_res: metadata.width,
134        y_res: metadata.height
135      });
136
137      resizeDeviceView();
138    }
139  }
140
141  const screensDiv = document.getElementById('screens');
142  function resizeDeviceView() {
143    // Auto-scale the screen based on window size.
144    // Max window width of 70%, allowing space for the control panel.
145    let ww = screensDiv.offsetWidth * 0.7;
146    let wh = screensDiv.offsetHeight;
147    let vw = currentDisplayDetails.x_res;
148    let vh = currentDisplayDetails.y_res;
149    let scaling = vw * wh > vh * ww ? ww / vw : wh / vh;
150    if (currentRotation == 0) {
151      deviceScreen.style.transform = null;
152      deviceScreen.style.width = vw * scaling;
153      deviceScreen.style.height = vh * scaling;
154    } else if (currentRotation == 1) {
155      deviceScreen.style.transform =
156          `rotateZ(-90deg) translateY(-${vh * scaling}px)`;
157      // When rotated, w and h are swapped.
158      deviceScreen.style.width = vh * scaling;
159      deviceScreen.style.height = vw * scaling;
160    }
161  }
162  window.onresize = resizeDeviceView;
163
164  function createControlPanelButton(command, title, icon_name,
165      listener=onControlPanelButton,
166      parent_id='control-panel-default-buttons') {
167    let button = document.createElement('button');
168    document.getElementById(parent_id).appendChild(button);
169    button.title = title;
170    button.dataset.command = command;
171    button.disabled = true;
172    // Capture mousedown/up/out commands instead of click to enable
173    // hold detection. mouseout is used to catch if the user moves the
174    // mouse outside the button while holding down.
175    button.addEventListener('mousedown', listener);
176    button.addEventListener('mouseup', listener);
177    button.addEventListener('mouseout', listener);
178    // Set the button image using Material Design icons.
179    // See http://google.github.io/material-design-icons
180    // and https://material.io/resources/icons
181    button.classList.add('material-icons');
182    button.innerHTML = icon_name;
183    buttons[command] = { 'button': button }
184    return buttons[command];
185  }
186  createControlPanelButton('power', 'Power', 'power_settings_new');
187  createControlPanelButton('home', 'Home', 'home');
188  createControlPanelButton('menu', 'Menu', 'menu');
189  createControlPanelButton('rotate', 'Rotate', 'screen_rotation', onRotateButton);
190  buttons['rotate'].adb = true;
191  createControlPanelButton('volumemute', 'Volume Mute', 'volume_mute');
192  createControlPanelButton('volumedown', 'Volume Down', 'volume_down');
193  createControlPanelButton('volumeup', 'Volume Up', 'volume_up');
194
195  let modalOffsets = {}
196  function createModalButton(button_id, modal_id, close_id) {
197    const modalButton = document.getElementById(button_id);
198    const modalDiv = document.getElementById(modal_id);
199    const modalHeader = modalDiv.querySelector('.modal-header');
200    const modalClose = document.getElementById(close_id);
201
202    // Position the modal to the right of the show modal button.
203    modalDiv.style.top = modalButton.offsetTop;
204    modalDiv.style.left = modalButton.offsetWidth + 30;
205
206    function showHideModal(show) {
207      if (show) {
208        modalButton.classList.add('modal-button-opened')
209        modalDiv.style.display = 'block';
210      } else {
211        modalButton.classList.remove('modal-button-opened')
212        modalDiv.style.display = 'none';
213      }
214    }
215    // Allow the show modal button to toggle the modal,
216    modalButton.addEventListener('click',
217        evt => showHideModal(modalDiv.style.display != 'block'));
218    // but the close button always closes.
219    modalClose.addEventListener('click',
220        evt => showHideModal(false));
221
222    // Allow the modal to be dragged by the header.
223    modalOffsets[modal_id] = {
224      midDrag: false,
225      mouseDownOffsetX: null,
226      mouseDownOffsetY: null,
227    }
228    modalHeader.addEventListener('mousedown',
229        evt => {
230            modalOffsets[modal_id].midDrag = true;
231            // Store the offset of the mouse location from the
232            // modal's current location.
233            modalOffsets[modal_id].mouseDownOffsetX =
234                parseInt(modalDiv.style.left) - evt.clientX;
235            modalOffsets[modal_id].mouseDownOffsetY =
236                parseInt(modalDiv.style.top) - evt.clientY;
237        });
238    modalHeader.addEventListener('mousemove',
239        evt => {
240            let offsets = modalOffsets[modal_id];
241            if (offsets.midDrag) {
242              // Move the modal to the mouse location plus the
243              // offset calculated on the initial mouse-down.
244              modalDiv.style.left =
245                  evt.clientX + offsets.mouseDownOffsetX;
246              modalDiv.style.top =
247                  evt.clientY + offsets.mouseDownOffsetY;
248            }
249        });
250    document.addEventListener('mouseup',
251        evt => {
252          modalOffsets[modal_id].midDrag = false;
253        });
254  }
255
256  createModalButton(
257    'device-details-button', 'device-details-modal', 'device-details-close');
258  createModalButton(
259    'bluetooth-console-button', 'bluetooth-console-modal', 'bluetooth-console-close');
260
261  let options = {
262    wsUrl: ((location.protocol == 'http:') ? 'ws://' : 'wss://') +
263      location.host + '/connect_client',
264  };
265
266  function showWebrtcError() {
267    statusMessage.className = 'error';
268    statusMessage.textContent = 'No connection to the guest device. ' +
269        'Please ensure the WebRTC process on the host machine is active.';
270    statusMessage.style.visibility = 'visible';
271    deviceScreen.style.display = 'none';
272    for (const [_, button] of Object.entries(buttons)) {
273      button.button.disabled = true;
274    }
275  }
276
277  import('./cf_webrtc.js')
278    .then(webrtcModule => webrtcModule.Connect(device_id, options))
279    .then(devConn => {
280      deviceConnection = devConn;
281      // TODO(b/143667633): get multiple display configuration from the
282      // description object
283      console.log(deviceConnection.description);
284      let stream_id = devConn.description.displays[0].stream_id;
285      devConn.getStream(stream_id).then(stream => {
286        videoStream = stream;
287        display_label = stream_id;
288        deviceScreen.srcObject = videoStream;
289      }).catch(e => console.error('Unable to get display stream: ', e));
290      for (const audio_desc of devConn.description.audio_streams) {
291        let stream_id = audio_desc.stream_id;
292        devConn.getStream(stream_id).then(stream => {
293          deviceAudio.srcObject = stream;
294        }).catch(e => console.error('Unable to get audio stream: ', e));
295      }
296      startMouseTracking();  // TODO stopMouseTracking() when disconnected
297      updateDeviceHardwareDetails(deviceConnection.description.hardware);
298      updateDeviceDisplayDetails(deviceConnection.description.displays[0]);
299      if (deviceConnection.description.custom_control_panel_buttons.length > 0) {
300        document.getElementById('control-panel-custom-buttons').style.display = 'flex';
301        for (const button of deviceConnection.description.custom_control_panel_buttons) {
302          if (button.shell_command) {
303            // This button's command is handled by sending an ADB shell command.
304            createControlPanelButton(button.command, button.title, button.icon_name,
305                e => onCustomShellButton(button.shell_command, e),
306                'control-panel-custom-buttons');
307            buttons[button.command].adb = true;
308          } else if (button.device_states) {
309            // This button corresponds to variable hardware device state(s).
310            createControlPanelButton(button.command, button.title, button.icon_name,
311                getCustomDeviceStateButtonCb(button.device_states),
312                'control-panel-custom-buttons');
313            for (const device_state of button.device_states) {
314              // hinge_angle is currently injected via an adb shell command that
315              // triggers a guest binary.
316              if ('hinge_angle_value' in device_state) {
317                buttons[button.command].adb = true;
318              }
319            }
320          } else {
321            // This button's command is handled by custom action server.
322            createControlPanelButton(button.command, button.title, button.icon_name,
323                onControlPanelButton,
324                'control-panel-custom-buttons');
325          }
326        }
327      }
328      deviceConnection.onControlMessage(msg => onControlMessage(msg));
329      // Start the screen as hidden. Only show when data is ready.
330      deviceScreen.style.visibility = 'hidden';
331      // Show the error message and disable buttons when the WebRTC connection fails.
332      deviceConnection.onConnectionStateChange(state => {
333        if (state == 'disconnected' || state == 'failed') {
334          showWebrtcError();
335        }
336      });
337      deviceConnection.onBluetoothMessage(msg => {
338        bluetoothConsole.addLine(decodeRootcanalMessage(msg));
339      });
340  }, rejection => {
341      console.error('Unable to connect: ', rejection);
342      showWebrtcError();
343  });
344
345  let hardwareDetailsText = '';
346  let displayDetailsText = '';
347  let deviceStateDetailsText = '';
348  function updateDeviceDetailsText() {
349    document.getElementById('device-details-hardware').textContent = [
350      hardwareDetailsText,
351      deviceStateDetailsText,
352      displayDetailsText,
353    ].filter(e => e /*remove empty*/).join('\n');
354  }
355  function updateDeviceHardwareDetails(hardware) {
356    let hardwareDetailsTextLines = [];
357    Object.keys(hardware).forEach(function(key) {
358      let value = hardware[key];
359      hardwareDetailsTextLines.push(`${key} - ${value}`);
360    });
361
362    hardwareDetailsText = hardwareDetailsTextLines.join('\n');
363    updateDeviceDetailsText();
364  }
365  function updateDeviceDisplayDetails(display) {
366    currentDisplayDetails = display;
367    let dpi = display.dpi;
368    let x_res = display.x_res;
369    let y_res = display.y_res;
370    let rotated = currentRotation == 1 ? ' (Rotated)' : '';
371    displayDetailsText = `Display - ${x_res}x${y_res} (${dpi}DPI)${rotated}`;
372    updateDeviceDetailsText();
373  }
374  function updateDeviceStateDetails() {
375    let deviceStateDetailsTextLines = [];
376    if (deviceStateLidSwitchOpen != null) {
377      let state = deviceStateLidSwitchOpen ? 'Opened' : 'Closed';
378      deviceStateDetailsTextLines.push(`Lid Switch - ${state}`);
379    }
380    if (deviceStateHingeAngleValue != null) {
381      deviceStateDetailsTextLines.push(`Hinge Angle - ${deviceStateHingeAngleValue}`);
382    }
383    deviceStateDetailsText = deviceStateDetailsTextLines.join('\n');
384    updateDeviceDetailsText();
385  }
386
387  function onKeyboardCaptureToggle(enabled) {
388    if (enabled) {
389      startKeyboardTracking();
390    } else {
391      stopKeyboardTracking();
392    }
393  }
394
395  function onMicCaptureToggle(enabled) {
396    deviceConnection.useMic(enabled);
397  }
398
399  function cmdConsole(consoleViewName, consoleInputName) {
400    let consoleView = document.getElementById(consoleViewName);
401
402    let addString = function(str) {
403      consoleView.value += str;
404      consoleView.scrollTop = consoleView.scrollHeight;
405    }
406
407    let addLine = function(line) {
408      addString(line + "\r\n");
409    }
410
411    let commandCallbacks = [];
412
413    let addCommandListener = function(f) {
414      commandCallbacks.push(f);
415    }
416
417    let onCommand = function(cmd) {
418      cmd = cmd.trim();
419
420      if (cmd.length == 0) return;
421
422      commandCallbacks.forEach(f => {
423        f(cmd);
424      })
425    }
426
427    addCommandListener(cmd => addLine(">> " + cmd));
428
429    let consoleInput = document.getElementById(consoleInputName);
430
431    consoleInput.addEventListener('keydown', e => {
432      if ((e.key && e.key == 'Enter') || e.keyCode == 13) {
433        let command = e.target.value;
434
435        e.target.value = '';
436
437        onCommand(command);
438      }
439    })
440
441    return {
442      consoleView: consoleView,
443      consoleInput: consoleInput,
444      addLine: addLine,
445      addString: addString,
446      addCommandListener: addCommandListener,
447    };
448  }
449
450  var bluetoothConsole = cmdConsole(
451    'bluetooth-console-view', 'bluetooth-console-input');
452
453  bluetoothConsole.addCommandListener(cmd => {
454    let inputArr = cmd.split(' ');
455    let command = inputArr[0];
456    inputArr.shift();
457    let args = inputArr;
458    deviceConnection.sendBluetoothMessage(createRootcanalMessage(command, args));
459  })
460
461  function onControlPanelButton(e) {
462    if (e.type == 'mouseout' && e.which == 0) {
463      // Ignore mouseout events if no mouse button is pressed.
464      return;
465    }
466    deviceConnection.sendControlMessage(JSON.stringify({
467      command: e.target.dataset.command,
468      button_state: e.type == 'mousedown' ? "down" : "up",
469    }));
470  }
471
472  function onRotateButton(e) {
473    // Attempt to init adb again, in case the initial connection failed.
474    // This succeeds immediately if already connected.
475    initializeAdb();
476    if (e.type == 'mousedown') {
477      adbShell(
478          '/vendor/bin/cuttlefish_sensor_injection rotate ' +
479          (currentRotation == 0 ? 'landscape' : 'portrait'))
480    }
481  }
482
483  function onCustomShellButton(shell_command, e) {
484    // Attempt to init adb again, in case the initial connection failed.
485    // This succeeds immediately if already connected.
486    initializeAdb();
487    if (e.type == 'mousedown') {
488      adbShell(shell_command);
489    }
490  }
491
492  function getCustomDeviceStateButtonCb(device_states) {
493    let states = device_states;
494    let index = 0;
495    return e => {
496      if (e.type == 'mousedown') {
497        // Reset any overridden device state.
498        adbShell('cmd device_state state reset');
499        // Send a device_state message for the current state.
500        let message = {
501          command: 'device_state',
502          ...states[index],
503        };
504        deviceConnection.sendControlMessage(JSON.stringify(message));
505        console.log(JSON.stringify(message));
506        if ('lid_switch_open' in states[index]) {
507          deviceStateLidSwitchOpen = states[index].lid_switch_open;
508        }
509        if ('hinge_angle_value' in states[index]) {
510          deviceStateHingeAngleValue = states[index].hinge_angle_value;
511          // TODO(b/181157794): Use a custom Sensor HAL for hinge_angle injection
512          // instead of this guest binary.
513          adbShell(
514              '/vendor/bin/cuttlefish_sensor_injection hinge_angle ' +
515              states[index].hinge_angle_value);
516        }
517        // Update the Device Details view.
518        updateDeviceStateDetails();
519        // Cycle to the next state.
520        index = (index + 1) % states.length;
521      }
522    }
523  }
524
525  function startMouseTracking() {
526    if (window.PointerEvent) {
527      deviceScreen.addEventListener('pointerdown', onStartDrag);
528      deviceScreen.addEventListener('pointermove', onContinueDrag);
529      deviceScreen.addEventListener('pointerup', onEndDrag);
530    } else if (window.TouchEvent) {
531      deviceScreen.addEventListener('touchstart', onStartDrag);
532      deviceScreen.addEventListener('touchmove', onContinueDrag);
533      deviceScreen.addEventListener('touchend', onEndDrag);
534    } else if (window.MouseEvent) {
535      deviceScreen.addEventListener('mousedown', onStartDrag);
536      deviceScreen.addEventListener('mousemove', onContinueDrag);
537      deviceScreen.addEventListener('mouseup', onEndDrag);
538    }
539  }
540
541  function stopMouseTracking() {
542    if (window.PointerEvent) {
543      deviceScreen.removeEventListener('pointerdown', onStartDrag);
544      deviceScreen.removeEventListener('pointermove', onContinueDrag);
545      deviceScreen.removeEventListener('pointerup', onEndDrag);
546    } else if (window.TouchEvent) {
547      deviceScreen.removeEventListener('touchstart', onStartDrag);
548      deviceScreen.removeEventListener('touchmove', onContinueDrag);
549      deviceScreen.removeEventListener('touchend', onEndDrag);
550    } else if (window.MouseEvent) {
551      deviceScreen.removeEventListener('mousedown', onStartDrag);
552      deviceScreen.removeEventListener('mousemove', onContinueDrag);
553      deviceScreen.removeEventListener('mouseup', onEndDrag);
554    }
555  }
556
557  function startKeyboardTracking() {
558    document.addEventListener('keydown', onKeyEvent);
559    document.addEventListener('keyup', onKeyEvent);
560  }
561
562  function stopKeyboardTracking() {
563    document.removeEventListener('keydown', onKeyEvent);
564    document.removeEventListener('keyup', onKeyEvent);
565  }
566
567  function onStartDrag(e) {
568    e.preventDefault();
569
570    // console.log("mousedown at " + e.pageX + " / " + e.pageY);
571    mouseIsDown = true;
572
573    sendEventUpdate(true, e);
574  }
575
576  function onEndDrag(e) {
577    e.preventDefault();
578
579    // console.log("mouseup at " + e.pageX + " / " + e.pageY);
580    mouseIsDown = false;
581
582    sendEventUpdate(false, e);
583  }
584
585  function onContinueDrag(e) {
586    e.preventDefault();
587
588    // console.log("mousemove at " + e.pageX + " / " + e.pageY + ", down=" +
589    // mouseIsDown);
590    if (mouseIsDown) {
591      sendEventUpdate(true, e);
592    }
593  }
594
595  function sendEventUpdate(down, e) {
596    console.assert(deviceConnection, 'Can\'t send mouse update without device');
597    var eventType = e.type.substring(0, 5);
598
599    // Before the first video frame arrives there is no way to know width and
600    // height of the device's screen, so turn every click into a click at 0x0.
601    // A click at that position is not more dangerous than anywhere else since
602    // the user is clicking blind anyways.
603    const videoWidth = deviceScreen.videoWidth? deviceScreen.videoWidth: 1;
604    const videoHeight = deviceScreen.videoHeight? deviceScreen.videoHeight: 1;
605    const elementWidth = deviceScreen.offsetWidth? deviceScreen.offsetWidth: 1;
606    const elementHeight = deviceScreen.offsetHeight? deviceScreen.offsetHeight: 1;
607
608    // vh*ew > eh*vw? then scale h instead of w
609    const scaleHeight = videoHeight * elementWidth > videoWidth * elementHeight;
610    var elementScaling = 0, videoScaling = 0;
611    if (scaleHeight) {
612      elementScaling = elementHeight;
613      videoScaling = videoHeight;
614    } else {
615      elementScaling = elementWidth;
616      videoScaling = videoWidth;
617    }
618
619    // The screen uses the 'object-fit: cover' property in order to completely
620    // fill the element while maintaining the screen content's aspect ratio.
621    // Therefore:
622    // - If vh*ew > eh*vw, w is scaled so that content width == element width
623    // - Otherwise,        h is scaled so that content height == element height
624    const scaleWidth = videoHeight * elementWidth > videoWidth * elementHeight;
625
626    // Convert to coordinates relative to the video by scaling.
627    // (This matches the scaling used by 'object-fit: cover'.)
628    //
629    // This scaling is needed to translate from the in-browser x/y to the
630    // on-device x/y.
631    //   - When the device screen has not been resized, this is simple: scale
632    //     the coordinates based on the ratio between the input video size and
633    //     the in-browser size.
634    //   - When the device screen has been resized, this scaling is still needed
635    //     even though the in-browser size and device size are identical. This
636    //     is due to the way WindowManager handles a resized screen, resized via
637    //     `adb shell wm size`:
638    //       - The ABS_X and ABS_Y max values of the screen retain their
639    //         original values equal to the value set when launching the device
640    //         (which equals the video size here).
641    //       - The sent ABS_X and ABS_Y values need to be scaled based on the
642    //         ratio between the max size (video size) and in-browser size.
643    const scaling = scaleWidth ? videoWidth / elementWidth : videoHeight / elementHeight;
644
645    var xArr = [];
646    var yArr = [];
647    var idArr = [];
648    var slotArr = [];
649
650    if (eventType == "mouse" || eventType == "point") {
651      xArr.push(e.offsetX);
652      yArr.push(e.offsetY);
653
654      let thisId = -1;
655      if (eventType == "point") {
656        thisId = e.pointerId;
657      }
658
659      slotArr.push(0);
660      idArr.push(thisId);
661    } else if (eventType == "touch") {
662      // touchstart: list of touch points that became active
663      // touchmove: list of touch points that changed
664      // touchend: list of touch points that were removed
665      let changes = e.changedTouches;
666      let rect = e.target.getBoundingClientRect();
667      for (var i=0; i < changes.length; i++) {
668        xArr.push(changes[i].pageX - rect.left);
669        yArr.push(changes[i].pageY - rect.top);
670        if (touchIdSlotMap.has(changes[i].identifier)) {
671          let slot = touchIdSlotMap.get(changes[i].identifier);
672
673          slotArr.push(slot);
674          if (e.type == 'touchstart') {
675            // error
676            console.error('touchstart when already have slot');
677            return;
678          } else if (e.type == 'touchmove') {
679            idArr.push(changes[i].identifier);
680          } else if (e.type == 'touchend') {
681            touchSlots[slot] = false;
682            touchIdSlotMap.delete(changes[i].identifier);
683            idArr.push(-1);
684          }
685        } else {
686          if (e.type == 'touchstart') {
687            let slot = -1;
688            for (var j=0; j < touchSlots.length; j++) {
689              if (!touchSlots[j]) {
690                slot = j;
691                break;
692              }
693            }
694            if (slot == -1) {
695              slot = touchSlots.length;
696              touchSlots.push(true);
697            }
698            slotArr.push(slot);
699            touchSlots[slot] = true;
700            touchIdSlotMap.set(changes[i].identifier, slot);
701            idArr.push(changes[i].identifier);
702          } else if (e.type == 'touchmove') {
703            // error
704            console.error('touchmove when no slot');
705            return;
706          } else if (e.type == 'touchend') {
707            // error
708            console.error('touchend when no slot');
709            return;
710          }
711        }
712      }
713    }
714
715    for (var i=0; i < xArr.length; i++) {
716      xArr[i] = xArr[i] * scaling;
717      yArr[i] = yArr[i] * scaling;
718
719      // Substract the offset produced by the difference in aspect ratio, if any.
720      if (scaleWidth) {
721        // Width was scaled, leaving excess content height, so subtract from y.
722        yArr[i] -= (elementHeight * scaling - videoHeight) / 2;
723      } else {
724        // Height was scaled, leaving excess content width, so subtract from x.
725        xArr[i] -= (elementWidth * scaling - videoWidth) / 2;
726      }
727
728      xArr[i] = Math.trunc(xArr[i]);
729      yArr[i] = Math.trunc(yArr[i]);
730    }
731
732    // NOTE: Rotation is handled automatically because the CSS rotation through
733    // transforms also rotates the coordinates of events on the object.
734
735    deviceConnection.sendMultiTouch(
736    {idArr, xArr, yArr, down, slotArr, display_label});
737  }
738
739  function onKeyEvent(e) {
740    e.preventDefault();
741    console.assert(deviceConnection, 'Can\'t send key event without device');
742    deviceConnection.sendKeyEvent(e.code, e.type);
743  }
744}
745
746/******************************************************************************/
747
748function ConnectDeviceCb(dev_id) {
749  console.log('Connect: ' + dev_id);
750  // Hide the device selection screen
751  document.getElementById('device-selector').style.display = 'none';
752  // Show the device control screen
753  document.getElementById('device-connection').style.visibility = 'visible';
754  ConnectToDevice(dev_id);
755}
756
757function ShowNewDeviceList(device_ids) {
758  let ul = document.getElementById('device-list');
759  ul.innerHTML = "";
760  let count = 1;
761  let device_to_button_map = {};
762  for (const dev_id of device_ids) {
763    const button_id = 'connect_' + count++;
764    ul.innerHTML += ('<li class="device_entry" title="Connect to ' + dev_id
765                     + '">' + dev_id + '<button id="' + button_id
766                     + '" >Connect</button></li>');
767    device_to_button_map[dev_id] = button_id;
768  }
769
770  for (const [dev_id, button_id] of Object.entries(device_to_button_map)) {
771    document.getElementById(button_id).addEventListener(
772        'click', evt => ConnectDeviceCb(dev_id));
773  }
774}
775
776function UpdateDeviceList() {
777  let url = ((location.protocol == 'http:') ? 'ws:' : 'wss:') + location.host +
778    '/list_devices';
779  let ws = new WebSocket(url);
780  ws.onopen = () => {
781    ws.send("give me those device ids");
782  };
783  ws.onmessage = msg => {
784   let device_ids = JSON.parse(msg.data);
785    ShowNewDeviceList(device_ids);
786  };
787}
788
789// Get any devices that are already connected
790UpdateDeviceList();
791// Update the list at the user's request
792document.getElementById('refresh-list')
793    .addEventListener('click', evt => UpdateDeviceList());
794