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 19// Set the theme as soon as possible. 20const params = new URLSearchParams(location.search); 21let theme = params.get('theme'); 22if (theme === 'light') { 23 document.querySelector('body').classList.add('light-theme'); 24} else if (theme === 'dark') { 25 document.querySelector('body').classList.add('dark-theme'); 26} 27 28async function ConnectDevice(deviceId, serverConnector) { 29 console.debug('Connect: ' + deviceId); 30 // Prepare messages in case of connection failure 31 let connectionAttemptDuration = 0; 32 const intervalMs = 15000; 33 let connectionInterval = setInterval(() => { 34 connectionAttemptDuration += intervalMs; 35 if (connectionAttemptDuration > 30000) { 36 showError( 37 'Connection should have occurred by now. ' + 38 'Please attempt to restart the guest device.'); 39 clearInterval(connectionInterval); 40 } else if (connectionAttemptDuration > 15000) { 41 showWarning('Connection is taking longer than expected'); 42 } 43 }, intervalMs); 44 45 let module = await import('./cf_webrtc.js'); 46 let deviceConnection = await module.Connect(deviceId, serverConnector); 47 console.info('Connected to ' + deviceId); 48 clearInterval(connectionInterval); 49 return deviceConnection; 50} 51 52function setupMessages() { 53 let closeBtn = document.querySelector('#error-message .close-btn'); 54 closeBtn.addEventListener('click', evt => { 55 evt.target.parentElement.className = 'hidden'; 56 }); 57} 58 59function showMessage(msg, className, duration) { 60 let element = document.getElementById('error-message'); 61 let previousTimeout = element.dataset.timeout; 62 if (previousTimeout !== undefined) { 63 clearTimeout(previousTimeout); 64 } 65 if (element.childNodes.length < 2) { 66 // First time, no text node yet 67 element.insertAdjacentText('afterBegin', msg); 68 } else { 69 element.childNodes[0].data = msg; 70 } 71 element.className = className; 72 73 if (duration !== undefined) { 74 element.dataset.timeout = setTimeout(() => { 75 element.className = 'hidden'; 76 }, duration); 77 } 78} 79 80function showInfo(msg, duration) { 81 showMessage(msg, 'info', duration); 82} 83 84function showWarning(msg, duration) { 85 showMessage(msg, 'warning', duration); 86} 87 88function showError(msg, duration) { 89 showMessage(msg, 'error', duration); 90} 91 92 93class DeviceDetailsUpdater { 94 #element; 95 96 constructor() { 97 this.#element = document.getElementById('device-details-hardware'); 98 } 99 100 setHardwareDetailsText(text) { 101 this.#element.dataset.hardwareDetailsText = text; 102 return this; 103 } 104 105 setDeviceStateDetailsText(text) { 106 this.#element.dataset.deviceStateDetailsText = text; 107 return this; 108 } 109 110 update() { 111 this.#element.textContent = 112 [ 113 this.#element.dataset.hardwareDetailsText, 114 this.#element.dataset.deviceStateDetailsText, 115 ].filter(e => e /*remove empty*/) 116 .join('\n'); 117 } 118} // DeviceDetailsUpdater 119 120// These classes provide the same interface as those from the server_connector, 121// but can't inherit from them because older versions of server_connector.js 122// don't provide them. 123// These classes are only meant to avoid having to check for null every time. 124class EmptyDeviceDisplaysMessage { 125 addDisplay(display_id, width, height) {} 126 send() {} 127} 128 129class EmptyParentController { 130 createDeviceDisplaysMessage(rotation) { 131 return new EmptyDeviceDisplaysMessage(); 132 } 133} 134 135class DeviceControlApp { 136 #deviceConnection = {}; 137 #parentController = null; 138 #currentRotation = 0; 139 #currentScreenStyles = {}; 140 #displayDescriptions = []; 141 #recording = {}; 142 #phys = {}; 143 #deviceCount = 0; 144 #micActive = false; 145 #adbConnected = false; 146 147 #displaySpecPresets = { 148 'display-spec-preset-phone': { 149 name: 'Phone (720x1280)', 150 width: 720, 151 height: 1280, 152 dpi: 320, 153 'refresh-rate-hz': 60 154 }, 155 'display-spec-preset-monitor': { 156 name: 'Monitor (1600x900)', 157 width: 1600, 158 height: 900, 159 dpi: 160, 160 'refresh-rate-hz': 60 161 } 162 }; 163 164 165 constructor(deviceConnection, parentController) { 166 this.#deviceConnection = deviceConnection; 167 this.#parentController = parentController; 168 } 169 170 start() { 171 console.debug('Device description: ', this.#deviceConnection.description); 172 this.#deviceConnection.onControlMessage(msg => this.#onControlMessage(msg)); 173 this.#deviceConnection.onLightsMessage(msg => this.#onLightsMessage(msg)); 174 this.#deviceConnection.onSensorsMessage(msg => this.#onSensorsMessage(msg)); 175 createToggleControl( 176 document.getElementById('camera_off_btn'), 177 enabled => this.#onCameraCaptureToggle(enabled)); 178 // disable the camera button if we are not using VSOCK camera 179 if (!this.#deviceConnection.description.hardware.camera_passthrough) { 180 document.getElementById('camera_off_btn').style.display = "none"; 181 } 182 createToggleControl( 183 document.getElementById('record_video_btn'), 184 enabled => this.#onVideoCaptureToggle(enabled)); 185 186 // Enable non-ADB buttons, these buttons use data channels to communicate 187 // with the host, so they're ready to go as soon as the webrtc connection is 188 // established. 189 this.#getControlPanelButtons() 190 .filter(b => !b.dataset.adb) 191 .forEach(b => b.disabled = false); 192 193 this.#showDeviceUI(); 194 } 195 196 #addAudioStream(stream_id, audioPlaybackCtrl) { 197 const audioId = `device-${stream_id}`; 198 if (document.getElementById(audioId)) { 199 console.warning(`Audio element with ID ${audioId} exists`); 200 return; 201 } 202 const deviceConnection = document.getElementById('device-connection'); 203 const audioElm = document.createElement('audio'); 204 audioElm.id = audioId; 205 audioElm.classList.add('device-audio'); 206 deviceConnection.appendChild(audioElm); 207 208 // The audio element may start or stop playing at any time, this ensures the 209 // audio control always show the right state. 210 audioElm.onplay = () => audioPlaybackCtrl.Set(true); 211 audioElm.onpause = () => audioPlaybackCtrl.Set(false); 212 deviceConnection.appendChild(audioElm); 213 } 214 215 #showDeviceUI() { 216 // Set up control panel buttons 217 addMouseListeners( 218 document.querySelector('#power_btn'), 219 evt => this.#onControlPanelButton(evt, 'power')); 220 addMouseListeners( 221 document.querySelector('#back_btn'), 222 evt => this.#onControlPanelButton(evt, 'back')); 223 addMouseListeners( 224 document.querySelector('#home_btn'), 225 evt => this.#onControlPanelButton(evt, 'home')); 226 addMouseListeners( 227 document.querySelector('#menu_btn'), 228 evt => this.#onControlPanelButton(evt, 'menu')); 229 addMouseListeners( 230 document.querySelector('#rotate_left_btn'), 231 evt => this.#onRotateLeftButton(evt, 'rotate')); 232 addMouseListeners( 233 document.querySelector('#rotate_right_btn'), 234 evt => this.#onRotateRightButton(evt, 'rotate')); 235 addMouseListeners( 236 document.querySelector('#volume_up_btn'), 237 evt => this.#onControlPanelButton(evt, 'volumeup')); 238 addMouseListeners( 239 document.querySelector('#volume_down_btn'), 240 evt => this.#onControlPanelButton(evt, 'volumedown')); 241 addMouseListeners( 242 document.querySelector('#mic_btn'), evt => this.#onMicButton(evt)); 243 244 createModalButton( 245 'device-details-button', 'device-details-modal', 246 'device-details-close'); 247 createModalButton( 248 'rotation-modal-button', 'rotation-modal', 249 'rotation-modal-close'); 250 createModalButton( 251 'touchpad-modal-button', 'touchpad-modal', 252 'touchpad-modal-close'); 253 createModalButton( 254 'bluetooth-modal-button', 'bluetooth-prompt', 'bluetooth-prompt-close'); 255 createModalButton( 256 'bluetooth-prompt-wizard', 'bluetooth-wizard', 'bluetooth-wizard-close', 257 'bluetooth-prompt'); 258 createModalButton( 259 'bluetooth-wizard-device', 'bluetooth-wizard-confirm', 260 'bluetooth-wizard-confirm-close', 'bluetooth-wizard'); 261 createModalButton( 262 'bluetooth-wizard-another', 'bluetooth-wizard', 263 'bluetooth-wizard-close', 'bluetooth-wizard-confirm'); 264 createModalButton( 265 'bluetooth-prompt-list', 'bluetooth-list', 'bluetooth-list-close', 266 'bluetooth-prompt'); 267 createModalButton( 268 'bluetooth-prompt-console', 'bluetooth-console', 269 'bluetooth-console-close', 'bluetooth-prompt'); 270 createModalButton( 271 'bluetooth-wizard-cancel', 'bluetooth-prompt', 'bluetooth-wizard-close', 272 'bluetooth-wizard'); 273 274 createModalButton('location-modal-button', 'location-prompt-modal', 275 'location-prompt-modal-close'); 276 createModalButton( 277 'location-set-wizard', 'location-set-modal', 'location-set-modal-close', 278 'location-prompt-modal'); 279 280 createModalButton( 281 'locations-import-wizard', 'locations-import-modal', 'locations-import-modal-close', 282 'location-prompt-modal'); 283 createModalButton( 284 'location-set-cancel', 'location-prompt-modal', 'location-set-modal-close', 285 'location-set-modal'); 286 createModalButton('keyboard-modal-button', 'keyboard-prompt-modal', 287 'keyboard-prompt-modal-close'); 288 createModalButton('display-add-modal-button', 'display-add-modal', 'display-add-modal-close'); 289 positionModal('rotation-modal-button', 'rotation-modal'); 290 positionModal('device-details-button', 'bluetooth-modal'); 291 positionModal('device-details-button', 'bluetooth-prompt'); 292 positionModal('device-details-button', 'bluetooth-wizard'); 293 positionModal('device-details-button', 'bluetooth-wizard-confirm'); 294 positionModal('device-details-button', 'bluetooth-list'); 295 positionModal('device-details-button', 'bluetooth-console'); 296 297 positionModal('device-details-button', 'location-modal'); 298 positionModal('device-details-button', 'location-prompt-modal'); 299 positionModal('device-details-button', 'location-set-modal'); 300 positionModal('device-details-button', 'locations-import-modal'); 301 302 positionModal('device-details-button', 'keyboard-prompt-modal'); 303 304 createButtonListener('bluetooth-prompt-list', null, this.#deviceConnection, 305 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 306 createButtonListener('bluetooth-wizard-device', null, this.#deviceConnection, 307 evt => this.#onRootCanalCommand(this.#deviceConnection, "add", evt)); 308 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 309 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 310 createButtonListener('bluetooth-prompt-wizard', null, this.#deviceConnection, 311 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 312 createButtonListener('bluetooth-wizard-another', null, this.#deviceConnection, 313 evt => this.#onRootCanalCommand(this.#deviceConnection, "list", evt)); 314 315 createButtonListener('locations-send-btn', null, this.#deviceConnection, 316 evt => this.#onImportLocationsFile(this.#deviceConnection,evt)); 317 318 createButtonListener('location-set-confirm', null, this.#deviceConnection, 319 evt => this.#onSendLocation(this.#deviceConnection, evt)); 320 321 createButtonListener('left-position-button', null, this.#deviceConnection, 322 () => this.#setOrientation(-90)); 323 createButtonListener('upright-position-button', null, this.#deviceConnection, 324 () => this.#setOrientation(0)); 325 326 createButtonListener('right-position-button', null, this.#deviceConnection, 327 () => this.#setOrientation(90)); 328 329 createButtonListener('upside-position-button', null, this.#deviceConnection, 330 () => this.#setOrientation(-180)); 331 332 createSliderListener('rotation-slider', () => this.#onMotionChanged(this.#deviceConnection)); 333 334 createSelectListener('display-spec-preset-select', () => this.#updateDisplaySpecFrom()); 335 createButtonListener('display-add-confirm', null, this.#deviceConnection, evt => this.#onDisplayAdditionConfirm(evt)); 336 337 if (this.#deviceConnection.description.custom_control_panel_buttons.length > 338 0) { 339 document.getElementById('control-panel-custom-buttons').style.display = 340 'flex'; 341 for (const button of this.#deviceConnection.description 342 .custom_control_panel_buttons) { 343 if (button.shell_command) { 344 // This button's command is handled by sending an ADB shell command. 345 let element = createControlPanelButton( 346 button.title, button.icon_name, 347 e => this.#onCustomShellButton(button.shell_command, e), 348 'control-panel-custom-buttons'); 349 element.dataset.adb = true; 350 } else if (button.device_states) { 351 // This button corresponds to variable hardware device state(s). 352 let element = createControlPanelButton( 353 button.title, button.icon_name, 354 this.#getCustomDeviceStateButtonCb(button.device_states), 355 'control-panel-custom-buttons'); 356 for (const device_state of button.device_states) { 357 // hinge_angle is currently injected via an adb shell command that 358 // triggers a guest binary. 359 if ('hinge_angle_value' in device_state) { 360 element.dataset.adb = true; 361 } 362 } 363 } else { 364 // This button's command is handled by custom action server. 365 createControlPanelButton( 366 button.title, button.icon_name, 367 evt => this.#onControlPanelButton(evt, button.command), 368 'control-panel-custom-buttons'); 369 } 370 } 371 } 372 373 if (this.#deviceConnection.description.mouse_enabled) { 374 // Enable mouse button conditionally. 375 enableMouseButton(this.#deviceConnection); 376 } 377 378 enableKeyboardRewriteButton(this.#deviceConnection); 379 380 // Set up displays 381 this.#updateDeviceDisplays(); 382 this.#deviceConnection.onStreamChange(stream => this.#onStreamChange(stream)); 383 384 // Set up audio 385 let audioPlaybackCtrl = createToggleControl( 386 document.getElementById('volume_off_btn'), 387 enabled => this.#onAudioPlaybackToggle(enabled)); 388 for (const audio_desc of this.#deviceConnection.description.audio_streams) { 389 let stream_id = audio_desc.stream_id; 390 this.#addAudioStream(stream_id, audioPlaybackCtrl); 391 this.#deviceConnection.onStream(stream_id) 392 .then(stream => { 393 const deviceAudio = document.getElementById(`device-${stream_id}`); 394 if (!deviceAudio) { 395 throw `Element with id device-${stream_id} not found`; 396 } 397 deviceAudio.srcObject = stream; 398 deviceAudio.play(); 399 }) 400 .catch(e => console.error('Unable to get audio stream: ', e)); 401 } 402 403 // Set up keyboard and wheel capture 404 this.#startKeyboardCapture(document.querySelector('#device-displays')); 405 this.#startWheelCapture(document.querySelector('#device-displays')); 406 407 this.#updateDeviceHardwareDetails( 408 this.#deviceConnection.description.hardware); 409 410 // Show the error message and disable buttons when the WebRTC connection 411 // fails. 412 this.#deviceConnection.onConnectionStateChange(state => { 413 if (state == 'disconnected' || state == 'failed') { 414 this.#showWebrtcError(); 415 } 416 }); 417 418 let bluetoothConsole = 419 cmdConsole('bluetooth-console-view', 'bluetooth-console-input'); 420 bluetoothConsole.addCommandListener(cmd => { 421 let inputArr = cmd.split(' '); 422 let command = inputArr[0]; 423 inputArr.shift(); 424 let args = inputArr; 425 this.#deviceConnection.sendBluetoothMessage( 426 createRootcanalMessage(command, args)); 427 }); 428 this.#deviceConnection.onBluetoothMessage(msg => { 429 let decoded = decodeRootcanalMessage(msg); 430 let deviceCount = btUpdateDeviceList(decoded); 431 console.debug("deviceCount= " +deviceCount); 432 console.debug("decoded= " +decoded); 433 if (deviceCount > 0) { 434 this.#deviceCount = deviceCount; 435 createButtonListener('bluetooth-list-trash', null, this.#deviceConnection, 436 evt => this.#onRootCanalCommand(this.#deviceConnection, "del", evt)); 437 } 438 btUpdateAdded(decoded); 439 let phyList = btParsePhys(decoded); 440 if (phyList) { 441 this.#phys = phyList; 442 } 443 bluetoothConsole.addLine(decoded); 444 }); 445 446 this.#deviceConnection.onLocationMessage(msg => { 447 console.debug("onLocationMessage = " +msg); 448 }); 449 450 this.#setupDisplaySpecPresetSelector(); 451 } 452 453 #onStreamChange(stream) { 454 let stream_id = stream.id; 455 if (stream_id.startsWith('display_')) { 456 this.#updateDeviceDisplays(); 457 } 458 } 459 460 #onRootCanalCommand(deviceConnection, cmd, evt) { 461 462 if (cmd == "list") { 463 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 464 } 465 if (cmd == "del") { 466 let id = evt.srcElement.getAttribute("data-device-id"); 467 deviceConnection.sendBluetoothMessage(createRootcanalMessage("del", [id])); 468 deviceConnection.sendBluetoothMessage(createRootcanalMessage("list", [])); 469 } 470 if (cmd == "add") { 471 let name = document.getElementById('bluetooth-wizard-name').value; 472 let type = document.getElementById('bluetooth-wizard-type').value; 473 if (type == "remote_loopback") { 474 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type])); 475 } else { 476 let mac = document.getElementById('bluetooth-wizard-mac').value; 477 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add", [type, mac])); 478 } 479 let phyId = this.#phys["LOW_ENERGY"].toString(); 480 if (type == "remote_loopback") { 481 phyId = this.#phys["BR_EDR"].toString(); 482 } 483 let devId = this.#deviceCount.toString(); 484 this.#deviceCount++; 485 deviceConnection.sendBluetoothMessage(createRootcanalMessage("add_device_to_phy", [devId, phyId])); 486 } 487 } 488 489 #onSendLocation(deviceConnection, evt) { 490 491 let longitude = document.getElementById('location-set-longitude').value; 492 let latitude = document.getElementById('location-set-latitude').value; 493 let altitude = document.getElementById('location-set-altitude').value; 494 if (longitude == null || longitude == '' || latitude == null || latitude == ''|| 495 altitude == null || altitude == '') { 496 return; 497 } 498 let location_msg = longitude + "," +latitude + "," + altitude; 499 deviceConnection.sendLocationMessage(location_msg); 500 } 501 502 async #onSensorsMessage(message) { 503 var decoder = new TextDecoder("utf-8"); 504 message = decoder.decode(message.data); 505 506 // Get sensor values from message. 507 var sensor_vals = message.split(" "); 508 var acc_update = sensor_vals[0].split(":").map((val) => parseFloat(val).toFixed(3)); 509 var gyro_update = sensor_vals[1].split(":").map((val) => parseFloat(val).toFixed(3)); 510 var mgn_update = sensor_vals[2].split(":").map((val) => parseFloat(val).toFixed(3)); 511 var xyz_update = sensor_vals[3].split(":").map((val) => parseFloat(val).toFixed(3)); 512 513 const acc_val = document.getElementById('accelerometer-value'); 514 const mgn_val = document.getElementById('magnetometer-value'); 515 const gyro_val = document.getElementById('gyroscope-value'); 516 const xyz_val = document.getElementsByClassName('rotation-slider-value'); 517 const xyz_range = document.getElementsByClassName('rotation-slider-range'); 518 519 // TODO: move to webrtc backend. 520 // Inject sensors with new values. 521 adbShell(`/vendor/bin/cuttlefish_sensor_injection motion ${acc_update[0]} ${acc_update[1]} ${acc_update[2]} ${mgn_update[0]} ${mgn_update[1]} ${mgn_update[2]} ${gyro_update[0]} ${gyro_update[1]} ${gyro_update[2]}`); 522 523 // Display new sensor values after injection. 524 acc_val.textContent = `${acc_update[0]} ${acc_update[1]} ${acc_update[2]}`; 525 mgn_val.textContent = `${mgn_update[0]} ${mgn_update[1]} ${mgn_update[2]}`; 526 gyro_val.textContent = `${gyro_update[0]} ${gyro_update[1]} ${gyro_update[2]}`; 527 528 // Update xyz sliders with backend values. 529 // This is needed for preserving device's state when display is turned on 530 // and off, and for having the same state for multiple clients. 531 for(let i = 0; i < 3; i++) { 532 xyz_val[i].textContent = xyz_update[i]; 533 xyz_range[i].value = xyz_update[i]; 534 } 535 } 536 537 // Send new rotation angles for sensor values' processing. 538 #onMotionChanged(deviceConnection = this.#deviceConnection) { 539 let values = document.getElementsByClassName('rotation-slider-value'); 540 let xyz = []; 541 for (var i = 0; i < values.length; i++) { 542 xyz[i] = values[i].textContent; 543 } 544 deviceConnection.sendSensorsMessage(`${xyz[0]} ${xyz[1]} ${xyz[2]}`); 545 } 546 547 // Gradually rotate to a fixed orientation. 548 #setOrientation(z) { 549 const sliders = document.getElementsByClassName('rotation-slider-range'); 550 const values = document.getElementsByClassName('rotation-slider-value'); 551 if (sliders.length != values.length && sliders.length != 3) { 552 return; 553 } 554 // Set XY axes to 0 (upright position). 555 sliders[0].value = '0'; 556 values[0].textContent = '0'; 557 sliders[1].value = '0'; 558 values[1].textContent = '0'; 559 560 // Gradually transition z axis to target angle. 561 let current_z = parseFloat(sliders[2].value); 562 const step = ((z > current_z) ? 0.5 : -0.5); 563 let move = setInterval(() => { 564 if (Math.abs(z - current_z) >= 0.5) { 565 current_z += step; 566 } 567 else { 568 current_z = z; 569 } 570 sliders[2].value = current_z; 571 values[2].textContent = `${current_z}`; 572 this.#onMotionChanged(); 573 if (current_z == z) { 574 this.#onMotionChanged(); 575 clearInterval(move); 576 } 577 }, 5); 578 } 579 580 #onImportLocationsFile(deviceConnection, evt) { 581 582 function onLoad_send_kml_data(xml) { 583 deviceConnection.sendKmlLocationsMessage(xml); 584 } 585 586 function onLoad_send_gpx_data(xml) { 587 deviceConnection.sendGpxLocationsMessage(xml); 588 } 589 590 let file_selector=document.getElementById("locations_select_file"); 591 592 if (!file_selector.files) { 593 alert("input parameter is not of file type"); 594 return; 595 } 596 597 if (!file_selector.files[0]) { 598 alert("Please select a file "); 599 return; 600 } 601 602 var filename= file_selector.files[0]; 603 if (filename.type.match('\gpx')) { 604 console.debug("import Gpx locations handling"); 605 loadFile(onLoad_send_gpx_data); 606 } else if(filename.type.match('\kml')){ 607 console.debug("import Kml locations handling"); 608 loadFile(onLoad_send_kml_data); 609 } 610 611 } 612 613 #setupDisplaySpecPresetSelector() { 614 const presetSelector = document.getElementById('display-spec-preset-select'); 615 for (const id in this.#displaySpecPresets) { 616 const option = document.createElement('option'); 617 option.value = id; 618 option.textContent = this.#displaySpecPresets[id].name; 619 presetSelector.appendChild(option); 620 } 621 622 const customOption = document.createElement('option'); 623 customOption.value = 'display-spec-custom'; 624 customOption.textContent = 'Custom'; 625 presetSelector.appendChild(customOption); 626 627 this.#updateDisplaySpecFrom(); 628 } 629 630 #updateDisplaySpecFrom() { 631 const presetSelector = document.getElementById('display-spec-preset-select'); 632 const selectedPreset = presetSelector.value; 633 634 const parameters = ['width', 'height', 'dpi', 'refresh-rate-hz']; 635 const applyToParameterInputs = (fn) => { 636 for (const parameter of parameters) { 637 const inputElement = document.getElementById('display-spec-' + parameter); 638 fn(inputElement, parameter); 639 } 640 } 641 642 if (selectedPreset == 'display-spec-custom') { 643 applyToParameterInputs((inputElement, parameter) => inputElement.disabled = false); 644 return; 645 } 646 647 const preset = this.#displaySpecPresets[selectedPreset]; 648 if (preset == undefined) { 649 console.error('Unknown preset is selected', selectedPreset); 650 return; 651 } 652 653 applyToParameterInputs((inputElement, parameter) => { 654 inputElement.value = preset[parameter]; 655 inputElement.disabled = true; 656 }); 657 } 658 659 #onDisplayAdditionConfirm(evt) { 660 if (evt.type != 'mousedown') { 661 return; 662 } 663 664 const getValue = (parameter) => { 665 const inputElement = document.getElementById('display-spec-' + parameter); 666 return inputElement.valueAsNumber; 667 } 668 669 const message = { 670 command: 'add-display', 671 width: getValue('width'), 672 height: getValue('height'), 673 dpi: getValue('dpi'), 674 refresh_rate_hz: getValue('refresh-rate-hz') 675 }; 676 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 677 } 678 679 #removeDisplay(displayId) { 680 const message = { 681 command: 'remove-display', 682 display_id: displayId 683 }; 684 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 685 } 686 687 #showWebrtcError() { 688 showError( 689 'No connection to the guest device. Please ensure the WebRTC' + 690 'process on the host machine is active.'); 691 const deviceDisplays = document.getElementById('device-displays'); 692 deviceDisplays.style.display = 'none'; 693 this.#getControlPanelButtons().forEach(b => b.disabled = true); 694 } 695 696 #getControlPanelButtons() { 697 return [ 698 ...document.querySelectorAll('#control-panel-default-buttons button'), 699 ...document.querySelectorAll('#control-panel-custom-buttons button'), 700 ]; 701 } 702 703 #takePhoto() { 704 const imageCapture = this.#deviceConnection.imageCapture; 705 if (imageCapture) { 706 const photoSettings = { 707 imageWidth: this.#deviceConnection.cameraWidth, 708 imageHeight: this.#deviceConnection.cameraHeight 709 }; 710 imageCapture.takePhoto(photoSettings) 711 .then(blob => blob.arrayBuffer()) 712 .then(buffer => this.#deviceConnection.sendOrQueueCameraData(buffer)) 713 .catch(error => console.error(error)); 714 } 715 } 716 717 #getCustomDeviceStateButtonCb(device_states) { 718 let states = device_states; 719 let index = 0; 720 return e => { 721 if (e.type == 'mousedown') { 722 // Reset any overridden device state. 723 adbShell('cmd device_state state reset'); 724 // Send a device_state message for the current state. 725 let message = { 726 command: 'device_state', 727 ...states[index], 728 }; 729 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 730 console.debug('Control message sent: ', JSON.stringify(message)); 731 let lidSwitchOpen = null; 732 if ('lid_switch_open' in states[index]) { 733 lidSwitchOpen = states[index].lid_switch_open; 734 } 735 let hingeAngle = null; 736 if ('hinge_angle_value' in states[index]) { 737 hingeAngle = states[index].hinge_angle_value; 738 // TODO(b/181157794): Use a custom Sensor HAL for hinge_angle 739 // injection instead of this guest binary. 740 adbShell( 741 '/vendor/bin/cuttlefish_sensor_injection hinge_angle ' + 742 states[index].hinge_angle_value); 743 } 744 // Update the Device Details view. 745 this.#updateDeviceStateDetails(lidSwitchOpen, hingeAngle); 746 // Cycle to the next state. 747 index = (index + 1) % states.length; 748 } 749 } 750 } 751 752 #rotateDisplays(rotation) { 753 if ((rotation - this.#currentRotation) % 360 == 0) { 754 return; 755 } 756 757 document.querySelectorAll('.device-display-video').forEach((v, i) => { 758 const width = v.videoWidth; 759 const height = v.videoHeight; 760 if (!width || !height) { 761 console.error('Stream dimensions not yet available?', v); 762 return; 763 } 764 765 const aspectRatio = width / height; 766 767 let keyFrames = []; 768 let from = this.#currentScreenStyles[v.id]; 769 if (from) { 770 // If the screen was already rotated, use that state as starting point, 771 // otherwise the animation will start at the element's default state. 772 keyFrames.push(from); 773 } 774 let to = getStyleAfterRotation(rotation, aspectRatio); 775 keyFrames.push(to); 776 v.animate(keyFrames, {duration: 400 /*ms*/, fill: 'forwards'}); 777 this.#currentScreenStyles[v.id] = to; 778 }); 779 780 this.#currentRotation = rotation; 781 this.#updateDeviceDisplaysInfo(); 782 } 783 784 #updateDeviceDisplaysInfo() { 785 let labels = document.querySelectorAll('.device-display-info'); 786 787 // #currentRotation is device's physical rotation and currently used to 788 // determine display's rotation. It would be obtained from device's 789 // accelerometer sensor. 790 let deviceDisplaysMessage = 791 this.#parentController.createDeviceDisplaysMessage( 792 this.#currentRotation); 793 794 labels.forEach(l => { 795 let deviceDisplay = l.closest('.device-display'); 796 if (deviceDisplay == null) { 797 console.error('Missing corresponding device display', l); 798 return; 799 } 800 801 let deviceDisplayVideo = 802 deviceDisplay.querySelector('.device-display-video'); 803 if (deviceDisplayVideo == null) { 804 console.error('Missing corresponding device display video', l); 805 return; 806 } 807 808 const DISPLAY_PREFIX = 'display_'; 809 let displayId = deviceDisplayVideo.id; 810 if (displayId == null || !displayId.startsWith(DISPLAY_PREFIX)) { 811 console.error('Unexpected device display video id', displayId); 812 return; 813 } 814 displayId = displayId.slice(DISPLAY_PREFIX.length); 815 816 let stream = deviceDisplayVideo.srcObject; 817 if (stream == null) { 818 console.error('Missing corresponding device display video stream', l); 819 return; 820 } 821 822 let text = `Display ${displayId} `; 823 824 let streamVideoTracks = stream.getVideoTracks(); 825 if (streamVideoTracks.length > 0) { 826 let streamSettings = stream.getVideoTracks()[0].getSettings(); 827 // Width and height may not be available immediately after the track is 828 // added but before frames arrive. 829 if ('width' in streamSettings && 'height' in streamSettings) { 830 let streamWidth = streamSettings.width; 831 let streamHeight = streamSettings.height; 832 833 deviceDisplaysMessage.addDisplay( 834 displayId, streamWidth, streamHeight); 835 836 text += `${streamWidth}x${streamHeight}`; 837 } 838 } 839 840 if (this.#currentRotation != 0) { 841 text += ` (Rotated ${this.#currentRotation}deg)` 842 } 843 844 const textElement = l.querySelector('.device-display-info-text'); 845 textElement.textContent = text; 846 }); 847 848 deviceDisplaysMessage.send(); 849 } 850 851 #onControlMessage(message) { 852 let message_data = JSON.parse(message.data); 853 console.debug('Control message received: ', message_data) 854 let metadata = message_data.metadata; 855 if (message_data.event == 'VIRTUAL_DEVICE_BOOT_STARTED') { 856 // Start the adb connection after receiving the BOOT_STARTED message. 857 // (This is after the adbd start message. Attempting to connect 858 // immediately after adbd starts causes issues.) 859 this.#initializeAdb(); 860 } 861 if (message_data.event == 'VIRTUAL_DEVICE_SCREEN_CHANGED') { 862 this.#rotateDisplays(+metadata.rotation); 863 } 864 if (message_data.event == 'VIRTUAL_DEVICE_CAPTURE_IMAGE') { 865 if (this.#deviceConnection.cameraEnabled) { 866 this.#takePhoto(); 867 } 868 } 869 if (message_data.event == 'VIRTUAL_DEVICE_DISPLAY_POWER_MODE_CHANGED') { 870 this.#deviceConnection.expectStreamChange(); 871 this.#updateDisplayVisibility(metadata.display, metadata.mode); 872 } 873 } 874 875 #onLightsMessage(message) { 876 let message_data = JSON.parse(message.data); 877 // TODO(286106270): Add an UI component for this 878 console.debug('Lights message received: ', message_data) 879 } 880 881 #updateDeviceStateDetails(lidSwitchOpen, hingeAngle) { 882 let deviceStateDetailsTextLines = []; 883 if (lidSwitchOpen != null) { 884 let state = lidSwitchOpen ? 'Opened' : 'Closed'; 885 deviceStateDetailsTextLines.push(`Lid Switch - ${state}`); 886 } 887 if (hingeAngle != null) { 888 deviceStateDetailsTextLines.push(`Hinge Angle - ${hingeAngle}`); 889 } 890 let deviceStateDetailsText = deviceStateDetailsTextLines.join('\n'); 891 new DeviceDetailsUpdater() 892 .setDeviceStateDetailsText(deviceStateDetailsText) 893 .update(); 894 } 895 896 #updateDeviceHardwareDetails(hardware) { 897 let hardwareDetailsTextLines = []; 898 Object.keys(hardware).forEach((key) => { 899 let value = hardware[key]; 900 hardwareDetailsTextLines.push(`${key} - ${value}`); 901 }); 902 903 let hardwareDetailsText = hardwareDetailsTextLines.join('\n'); 904 new DeviceDetailsUpdater() 905 .setHardwareDetailsText(hardwareDetailsText) 906 .update(); 907 } 908 909 // Creates a <video> element and a <div> container element for each display. 910 // The extra <div> container elements are used to maintain the width and 911 // height of the device as the CSS 'transform' property used on the <video> 912 // element for rotating the device only affects the visuals of the element 913 // and not its layout. 914 #updateDeviceDisplays() { 915 let anyDisplayLoaded = false; 916 const deviceDisplays = document.getElementById('device-displays'); 917 918 const MAX_DISPLAYS = 16; 919 for (let i = 0; i < MAX_DISPLAYS; i++) { 920 const display_id = i.toString(); 921 const stream_id = 'display_' + display_id; 922 const stream = this.#deviceConnection.getStream(stream_id); 923 924 let deviceDisplayVideo = document.querySelector('#' + stream_id); 925 const deviceDisplayIsPresent = deviceDisplayVideo != null; 926 const deviceDisplayStreamIsActive = stream != null && stream.active; 927 if (deviceDisplayStreamIsActive == deviceDisplayIsPresent) { 928 continue; 929 } 930 931 if (deviceDisplayStreamIsActive) { 932 console.debug('Adding display', i); 933 934 let displayFragment = 935 document.querySelector('#display-template').content.cloneNode(true); 936 937 let deviceDisplayInfo = 938 displayFragment.querySelector('.device-display-info'); 939 deviceDisplayInfo.id = stream_id + '_info'; 940 941 let deviceDisplayRemoveButton = 942 displayFragment.querySelector('.device-display-remove-button'); 943 deviceDisplayRemoveButton.id = stream_id + '_remove_button'; 944 deviceDisplayRemoveButton.addEventListener('mousedown', () => { 945 this.#removeDisplay(display_id); 946 }); 947 948 deviceDisplayVideo = displayFragment.querySelector('video'); 949 deviceDisplayVideo.id = stream_id; 950 deviceDisplayVideo.srcObject = stream; 951 deviceDisplayVideo.addEventListener('loadeddata', (evt) => { 952 if (!anyDisplayLoaded) { 953 anyDisplayLoaded = true; 954 this.#onDeviceDisplayLoaded(); 955 } 956 }); 957 deviceDisplayVideo.addEventListener('loadedmetadata', (evt) => { 958 this.#updateDeviceDisplaysInfo(); 959 }); 960 961 this.#addMouseTracking(deviceDisplayVideo, scaleDisplayCoordinates); 962 963 deviceDisplays.appendChild(displayFragment); 964 965 // Confusingly, events for adding tracks occur on the peer connection 966 // but events for removing tracks occur on the stream. 967 stream.addEventListener('removetrack', evt => { 968 this.#updateDeviceDisplays(); 969 }); 970 971 this.#requestNewFrameForDisplay(i); 972 } else { 973 console.debug('Removing display', i); 974 975 let deviceDisplay = deviceDisplayVideo.closest('.device-display'); 976 if (deviceDisplay == null) { 977 console.error('Failed to find device display for ', stream_id); 978 } else { 979 deviceDisplays.removeChild(deviceDisplay); 980 } 981 } 982 } 983 984 this.#updateDeviceDisplaysInfo(); 985 } 986 987 #requestNewFrameForDisplay(display_number) { 988 let message = { 989 command: "display", 990 refresh_display: display_number, 991 }; 992 this.#deviceConnection.sendControlMessage(JSON.stringify(message)); 993 console.debug('Control message sent: ', JSON.stringify(message)); 994 } 995 996 #initializeAdb() { 997 init_adb( 998 this.#deviceConnection, () => this.#onAdbConnected(), 999 () => this.#showAdbError()); 1000 } 1001 1002 #onAdbConnected() { 1003 if (this.#adbConnected) { 1004 return; 1005 } 1006 // Screen changed messages are not reported until after boot has completed. 1007 // Certain default adb buttons change screen state, so wait for boot 1008 // completion before enabling these buttons. 1009 showInfo('adb connection established successfully.', 5000); 1010 this.#adbConnected = true; 1011 this.#getControlPanelButtons() 1012 .filter(b => b.dataset.adb) 1013 .forEach(b => b.disabled = false); 1014 } 1015 1016 #showAdbError() { 1017 showError('adb connection failed.'); 1018 this.#getControlPanelButtons() 1019 .filter(b => b.dataset.adb) 1020 .forEach(b => b.disabled = true); 1021 } 1022 1023 #initializeTouchpads() { 1024 const touchpadListElem = document.getElementById("touchpad-list"); 1025 const touchpadElementContainer = touchpadListElem.querySelector(".touchpads"); 1026 const touchpadSelectorContainer = touchpadListElem.querySelector(".selectors"); 1027 const touchpads = this.#deviceConnection.description.touchpads; 1028 1029 let setActiveTouchpad = (tab_touchpad_id, touchpad_num) => { 1030 const touchPadElem = document.getElementById(tab_touchpad_id); 1031 const tabButtonElem = document.getElementById("touch_button_" + touchpad_num); 1032 1033 touchpadElementContainer.querySelectorAll(".selected").forEach(e => e.classList.remove("selected")); 1034 touchpadSelectorContainer.querySelectorAll(".selected").forEach(e => e.classList.remove("selected")); 1035 1036 touchPadElem.classList.add("selected"); 1037 tabButtonElem.classList.add("selected"); 1038 }; 1039 1040 for (let i = 0; i < touchpads.length; i++) { 1041 const touchpad = touchpads[i]; 1042 1043 let touchPadElem = document.createElement("div"); 1044 touchPadElem.classList.add("touchpad"); 1045 touchPadElem.style.aspectRatio = touchpad.x_res / touchpad.y_res; 1046 touchPadElem.id = touchpad.label; 1047 this.#addMouseTracking(touchPadElem, makeScaleTouchpadCoordinates(touchpad)); 1048 touchpadElementContainer.appendChild(touchPadElem); 1049 1050 let tabButtonElem = document.createElement("button"); 1051 tabButtonElem.id = "touch_button_" + i; 1052 tabButtonElem.innerHTML = "Touchpad " + i; 1053 tabButtonElem.class = "touchpad-tab-button" 1054 tabButtonElem.onclick = () => { 1055 setActiveTouchpad(touchpad.label, i); 1056 }; 1057 touchpadSelectorContainer.appendChild(tabButtonElem); 1058 } 1059 1060 if (touchpads.length > 0) { 1061 document.getElementById("touchpad-modal-button").style.display = "block"; 1062 setActiveTouchpad(touchpads[0].label, 0); 1063 } 1064 } 1065 1066 #onDeviceDisplayLoaded() { 1067 if (!this.#adbConnected) { 1068 // ADB may have connected before, don't show this message in that case 1069 showInfo('Awaiting bootup and adb connection. Please wait...', 10000); 1070 } 1071 this.#updateDeviceDisplaysInfo(); 1072 1073 let deviceDisplayList = document.getElementsByClassName('device-display'); 1074 for (const deviceDisplay of deviceDisplayList) { 1075 deviceDisplay.style.visibility = 'visible'; 1076 } 1077 1078 this.#initializeTouchpads(); 1079 1080 // Start the adb connection if it is not already started. 1081 this.#initializeAdb(); 1082 // TODO(b/297361564) 1083 this.#onMotionChanged(); 1084 } 1085 1086 #onRotateLeftButton(e) { 1087 if (e.type == 'mousedown') { 1088 this.#onRotateButton(this.#currentRotation + 90); 1089 } 1090 } 1091 1092 #onRotateRightButton(e) { 1093 if (e.type == 'mousedown') { 1094 this.#onRotateButton(this.#currentRotation - 90); 1095 } 1096 } 1097 1098 #onRotateButton(rotation) { 1099 // Attempt to init adb again, in case the initial connection failed. 1100 // This succeeds immediately if already connected. 1101 this.#initializeAdb(); 1102 this.#rotateDisplays(rotation); 1103 adbShell(`/vendor/bin/cuttlefish_sensor_injection rotate ${rotation}`); 1104 } 1105 1106 #onControlPanelButton(e, command) { 1107 if (e.type == 'mouseout' && e.which == 0) { 1108 // Ignore mouseout events if no mouse button is pressed. 1109 return; 1110 } 1111 this.#deviceConnection.sendControlMessage(JSON.stringify({ 1112 command: command, 1113 button_state: e.type == 'mousedown' ? 'down' : 'up', 1114 })); 1115 } 1116 1117 #startKeyboardCapture(elem) { 1118 elem.addEventListener('keydown', evt => this.#onKeyEvent(evt)); 1119 elem.addEventListener('keyup', evt => this.#onKeyEvent(evt)); 1120 } 1121 1122 #onKeyEvent(e) { 1123 if (e.cancelable) { 1124 // Some keyboard events cause unwanted side effects, like elements losing 1125 // focus, if the default behavior is not prevented. 1126 e.preventDefault(); 1127 } 1128 this.#deviceConnection.sendKeyEvent(e.code, e.type); 1129 } 1130 1131 #startWheelCapture(elm) { 1132 elm.addEventListener('wheel', evt => this.#onWheelEvent(evt), 1133 { passive: false }); 1134 } 1135 1136 #onWheelEvent(e) { 1137 e.preventDefault(); 1138 // Vertical wheel pixel events only 1139 if (e.deltaMode == WheelEvent.DOM_DELTA_PIXEL && e.deltaY != 0.0) { 1140 this.#deviceConnection.sendWheelEvent(e.deltaY); 1141 } 1142 } 1143 1144 #addMouseTracking(touchInputElement, scaleCoordinates) { 1145 trackPointerEvents(touchInputElement, this.#deviceConnection, scaleCoordinates); 1146 } 1147 1148 #updateDisplayVisibility(displayId, powerMode) { 1149 const displayVideo = document.getElementById('display_' + displayId); 1150 if (displayVideo == null) { 1151 console.error('Unknown display id: ' + displayId); 1152 return; 1153 } 1154 1155 const display = displayVideo.parentElement; 1156 if (display == null) { 1157 console.error('Unknown display id: ' + displayId); 1158 return; 1159 } 1160 1161 const display_number = parseInt(displayId); 1162 if (isNaN(display_number)) { 1163 console.error('Invalid display id: ' + displayId); 1164 return; 1165 } 1166 1167 powerMode = powerMode.toLowerCase(); 1168 switch (powerMode) { 1169 case 'on': 1170 display.style.visibility = 'visible'; 1171 this.#requestNewFrameForDisplay(display_number); 1172 break; 1173 case 'off': 1174 display.style.visibility = 'hidden'; 1175 break; 1176 default: 1177 console.error('Display ' + displayId + ' has unknown display power mode: ' + powerMode); 1178 } 1179 } 1180 1181 #onMicButton(evt) { 1182 let nextState = evt.type == 'mousedown'; 1183 if (this.#micActive == nextState) { 1184 return; 1185 } 1186 this.#micActive = nextState; 1187 this.#deviceConnection.useMic(nextState, 1188 () => document.querySelector('#mic_btn').innerHTML = 'mic', 1189 () => document.querySelector('#mic_btn').innerHTML = 'mic_off'); 1190 } 1191 1192 #onCameraCaptureToggle(enabled) { 1193 return this.#deviceConnection.useCamera(enabled); 1194 } 1195 1196 #getZeroPaddedString(value, desiredLength) { 1197 const s = String(value); 1198 return '0'.repeat(desiredLength - s.length) + s; 1199 } 1200 1201 #getTimestampString() { 1202 const now = new Date(); 1203 return [ 1204 now.getFullYear(), 1205 this.#getZeroPaddedString(now.getMonth(), 2), 1206 this.#getZeroPaddedString(now.getDay(), 2), 1207 this.#getZeroPaddedString(now.getHours(), 2), 1208 this.#getZeroPaddedString(now.getMinutes(), 2), 1209 this.#getZeroPaddedString(now.getSeconds(), 2), 1210 ].join('_'); 1211 } 1212 1213 #onVideoCaptureToggle(enabled) { 1214 const recordToggle = document.getElementById('record-video-control'); 1215 if (enabled) { 1216 let recorders = []; 1217 1218 const timestamp = this.#getTimestampString(); 1219 1220 let deviceDisplayVideoList = 1221 document.getElementsByClassName('device-display-video'); 1222 for (let i = 0; i < deviceDisplayVideoList.length; i++) { 1223 const deviceDisplayVideo = deviceDisplayVideoList[i]; 1224 1225 const recorder = new MediaRecorder(deviceDisplayVideo.captureStream()); 1226 const recordedData = []; 1227 1228 recorder.ondataavailable = event => recordedData.push(event.data); 1229 recorder.onstop = event => { 1230 const recording = new Blob(recordedData, { type: "video/webm" }); 1231 1232 const downloadLink = document.createElement('a'); 1233 downloadLink.setAttribute('download', timestamp + '_display_' + i + '.webm'); 1234 downloadLink.setAttribute('href', URL.createObjectURL(recording)); 1235 downloadLink.click(); 1236 }; 1237 1238 recorder.start(); 1239 recorders.push(recorder); 1240 } 1241 this.#recording['recorders'] = recorders; 1242 1243 recordToggle.style.backgroundColor = 'red'; 1244 } else { 1245 for (const recorder of this.#recording['recorders']) { 1246 recorder.stop(); 1247 } 1248 recordToggle.style.backgroundColor = ''; 1249 } 1250 return Promise.resolve(enabled); 1251 } 1252 1253 #onAudioPlaybackToggle(enabled) { 1254 const audioElements = document.getElementsByClassName('device-audio'); 1255 for (let i = 0; i < audioElements.length; i++) { 1256 const audioElem = audioElements[i]; 1257 if (enabled) { 1258 audioElem.play(); 1259 } else { 1260 audioElem.pause(); 1261 } 1262 } 1263 } 1264 1265 #onCustomShellButton(shell_command, e) { 1266 // Attempt to init adb again, in case the initial connection failed. 1267 // This succeeds immediately if already connected. 1268 this.#initializeAdb(); 1269 if (e.type == 'mousedown') { 1270 adbShell(shell_command); 1271 } 1272 } 1273} // DeviceControlApp 1274 1275window.addEventListener("load", async evt => { 1276 try { 1277 setupMessages(); 1278 let connectorModule = await import('./server_connector.js'); 1279 let deviceId = connectorModule.deviceId(); 1280 document.title = deviceId; 1281 let deviceConnection = 1282 await ConnectDevice(deviceId, await connectorModule.createConnector()); 1283 let parentController = null; 1284 if (connectorModule.createParentController) { 1285 parentController = connectorModule.createParentController(); 1286 } 1287 if (!parentController) { 1288 parentController = new EmptyParentController(); 1289 } 1290 let deviceControlApp = new DeviceControlApp(deviceConnection, parentController); 1291 deviceControlApp.start(); 1292 document.getElementById('device-connection').style.display = 'block'; 1293 } catch(err) { 1294 console.error('Unable to connect: ', err); 1295 showError( 1296 'No connection to the guest device. ' + 1297 'Please ensure the WebRTC process on the host machine is active.'); 1298 } 1299 document.getElementById('loader').style.display = 'none'; 1300}); 1301 1302// The formulas in this function are derived from the following facts: 1303// * The video element's aspect ratio (ar) is fixed. 1304// * CSS rotations are centered on the geometrical center of the element. 1305// * The aspect ratio is the tangent of the angle between the left-top to 1306// right-bottom diagonal (d) and the left side. 1307// * d = w/sin(arctan(ar)) = h/cos(arctan(ar)), with w = width and h = height. 1308// * After any rotation, the element's total width is the maximum size of the 1309// projection of the diagonals on the X axis (Y axis for height). 1310// Deriving the formulas is left as an exercise to the reader. 1311function getStyleAfterRotation(rotationDeg, ar) { 1312 // Convert the rotation angle to radians 1313 let r = Math.PI * rotationDeg / 180; 1314 1315 // width <= parent_with / abs(cos(r) + sin(r)/ar) 1316 // and 1317 // width <= parent_with / abs(cos(r) - sin(r)/ar) 1318 let den1 = Math.abs((Math.sin(r) / ar) + Math.cos(r)); 1319 let den2 = Math.abs((Math.sin(r) / ar) - Math.cos(r)); 1320 let denominator = Math.max(den1, den2); 1321 let maxWidth = `calc(100% / ${denominator})`; 1322 1323 // height <= parent_height / abs(cos(r) + sin(r)*ar) 1324 // and 1325 // height <= parent_height / abs(cos(r) - sin(r)*ar) 1326 den1 = Math.abs(Math.cos(r) - (Math.sin(r) * ar)); 1327 den2 = Math.abs(Math.cos(r) + (Math.sin(r) * ar)); 1328 denominator = Math.max(den1, den2); 1329 let maxHeight = `calc(100% / ${denominator})`; 1330 1331 // rotated_left >= left * (abs(cos(r)+sin(r)/ar)-1)/2 1332 // and 1333 // rotated_left >= left * (abs(cos(r)-sin(r)/ar)-1)/2 1334 let tmp1 = Math.max( 1335 Math.abs(Math.cos(r) + (Math.sin(r) / ar)), 1336 Math.abs(Math.cos(r) - (Math.sin(r) / ar))); 1337 let leftFactor = (tmp1 - 1) / 2; 1338 // rotated_top >= top * (abs(cos(r)+sin(r)*ar)-1)/2 1339 // and 1340 // rotated_top >= top * (abs(cos(r)-sin(r)*ar)-1)/2 1341 let tmp2 = Math.max( 1342 Math.abs(Math.cos(r) - (Math.sin(r) * ar)), 1343 Math.abs(Math.cos(r) + (Math.sin(r) * ar))); 1344 let rightFactor = (tmp2 - 1) / 2; 1345 1346 // CSS rotations are in the opposite direction as Android screen rotations 1347 rotationDeg = -rotationDeg; 1348 1349 let transform = `translate(calc(100% * ${leftFactor}), calc(100% * ${ 1350 rightFactor})) rotate(${rotationDeg}deg)`; 1351 1352 return {transform, maxWidth, maxHeight}; 1353} 1354