local-kvm.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. /*
  2. DezuKVM - Offline USB KVM Client
  3. Author: tobychui
  4. Note: This require HTTPS and user interaction to request serial port access.
  5. This file is part of DezuKVM.
  6. DezuKVM is free software: you can redistribute it and/or modify
  7. it under the terms of the GNU General Public License as published by
  8. the Free Software Foundation, either version 3 of the License, or
  9. (at your option) any later version.
  10. */
  11. /*
  12. USB Serial Communication
  13. */
  14. let serialPort = null;
  15. let serialReader = null;
  16. let serialWriter = null;
  17. let serialReadBuffer = [];
  18. // Update selected port display
  19. function updateSelectedPortDisplay(port) {
  20. const selectedPortElem = document.getElementById('selectedPort');
  21. if (port && port.getInfo) {
  22. const info = port.getInfo();
  23. selectedPortElem.textContent = `VID: ${info.usbVendorId || '-'}, PID: ${info.usbProductId || '-'}`;
  24. } else if (port) {
  25. selectedPortElem.textContent = 'KVM Connected';
  26. } else {
  27. selectedPortElem.textContent = 'KVM Not Connected';
  28. }
  29. }
  30. // Request a new serial port
  31. async function requestSerialPort() {
  32. try {
  33. // Disconnect previous port if connected
  34. if (serialPort) {
  35. await disconnectSerialPort();
  36. }
  37. serialPort = await navigator.serial.requestPort();
  38. await serialPort.open({ baudRate: 115200 });
  39. serialReader = serialPort.readable.getReader();
  40. serialWriter = serialPort.writable.getWriter();
  41. updateSelectedPortDisplay(serialPort);
  42. // Change button to indicate connected state
  43. document.getElementById('selectSerialPort').classList.add('is-negative');
  44. document.querySelector('#selectSerialPort span').className = 'ts-icon is-link-slash-icon';
  45. // Start reading loop for incoming data
  46. readSerialLoop();
  47. } catch (e) {
  48. updateSelectedPortDisplay(null);
  49. alert('Failed to open serial port');
  50. }
  51. }
  52. // Disconnect serial port
  53. async function disconnectSerialPort() {
  54. try {
  55. if (serialReader) {
  56. await serialReader.cancel();
  57. serialReader.releaseLock();
  58. serialReader = null;
  59. }
  60. if (serialWriter) {
  61. serialWriter.releaseLock();
  62. serialWriter = null;
  63. }
  64. if (serialPort) {
  65. await serialPort.close();
  66. serialPort = null;
  67. }
  68. } catch (e) {}
  69. updateSelectedPortDisplay(null);
  70. }
  71. // Read loop for incoming serial data, dispatches 'data' events on parent
  72. async function readSerialLoop() {
  73. while (serialPort && serialReader) {
  74. try {
  75. const { value, done } = await serialReader.read();
  76. if (done) break;
  77. if (value) {
  78. // Append to buffer
  79. //serialReadBuffer.push(...value);
  80. //console.log('Received data:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(' '));
  81. }
  82. } catch (e) {
  83. break;
  84. }
  85. }
  86. }
  87. // Send data over serial
  88. async function sendSerial(data) {
  89. if (!serialWriter) throw new Error('Serial port not open');
  90. await serialWriter.write(data);
  91. }
  92. // Button event to select serial port
  93. document.getElementById('selectSerialPort').addEventListener('click', function(){
  94. if (serialPort) {
  95. disconnectSerialPort();
  96. document.getElementById('selectSerialPort').classList.remove('is-negative');
  97. document.querySelector('#selectSerialPort span').className = 'ts-icon is-keyboard-icon';
  98. } else {
  99. requestSerialPort();
  100. }
  101. });
  102. /*
  103. CH9329 HID bytecode converter
  104. */
  105. function resizeTouchscreenToVideo() {
  106. const video = document.getElementById('video');
  107. const touchscreen = document.getElementById('touchscreen');
  108. if (video && touchscreen) {
  109. const rect = video.getBoundingClientRect();
  110. const resolution = getResolutionFromCurrentStream();
  111. // Dynamically get video resolution and aspect ratio
  112. let aspectRatio = 16 / 9; // default
  113. if (resolution && resolution.width && resolution.height) {
  114. aspectRatio = resolution.width / resolution.height;
  115. }
  116. let displayWidth = rect.width;
  117. let displayHeight = rect.height;
  118. let offsetX = 0;
  119. let offsetY = 0;
  120. // Calculate the actual displayed video area (may be letterboxed/pillarboxed)
  121. if (rect.width / rect.height > aspectRatio) {
  122. // Pillarbox: black bars left/right
  123. displayHeight = rect.height;
  124. displayWidth = rect.height * aspectRatio;
  125. offsetX = rect.left + (rect.width - displayWidth) / 2;
  126. offsetY = rect.top;
  127. } else {
  128. // Letterbox: black bars top/bottom
  129. displayWidth = rect.width;
  130. displayHeight = rect.width / aspectRatio;
  131. offsetX = rect.left;
  132. offsetY = rect.top + (rect.height - displayHeight) / 2;
  133. }
  134. touchscreen.style.position = 'absolute';
  135. touchscreen.style.left = offsetX + 'px';
  136. touchscreen.style.top = offsetY + 'px';
  137. touchscreen.style.width = displayWidth + 'px';
  138. touchscreen.style.height = displayHeight + 'px';
  139. touchscreen.width = displayWidth;
  140. touchscreen.height = displayHeight;
  141. }
  142. }
  143. // Call on load and on resize
  144. window.addEventListener('resize', resizeTouchscreenToVideo);
  145. window.addEventListener('DOMContentLoaded', resizeTouchscreenToVideo);
  146. setTimeout(resizeTouchscreenToVideo, 1000); // Also after 1s to ensure video is loaded
  147. class HIDController {
  148. constructor() {
  149. this.hidState = {
  150. MouseButtons: 0x00,
  151. Modkey: 0x00,
  152. KeyboardButtons: [0, 0, 0, 0, 0, 0]
  153. };
  154. this.Config = {
  155. ScrollSensitivity: 1
  156. };
  157. }
  158. // Calculates checksum for a given array of bytes
  159. calcChecksum(arr) {
  160. return arr.reduce((sum, b) => (sum + b) & 0xFF, 0);
  161. }
  162. // Soft reset the CH9329 chip
  163. async softReset() {
  164. if (!serialPort || !serialPort.readable || !serialPort.writable) {
  165. throw new Error('Serial port not open');
  166. }
  167. const packet = [
  168. 0x57, 0xAB, 0x00, 0x0F, 0x00 // checksum placeholder
  169. ];
  170. packet[4] = this.calcChecksum(packet.slice(0, 4));
  171. await this.sendPacketAndWait(packet, 0x0F);
  172. }
  173. // Sends a packet over serial and waits for a reply with a specific command code
  174. async sendPacketAndWait(packet, replyCmd) {
  175. const timeout = 300; // 300ms timeout
  176. const succReplyByte = replyCmd | 0x80;
  177. const errorReplyByte = replyCmd | 0xC0;
  178. // Succ example for cmd 0x04: 57 AB 00 84 01 00 87
  179. // Header is 57 AB 00, we can skip that
  180. // then the 0x84 is the replyCmd | 0x80 (or if error, 0xC4)
  181. // 0x01 is the data length (1 byte)
  182. // 0x00 is the data (success)
  183. // 0x87 is the checksum
  184. serialReadBuffer = [];
  185. await sendSerial(new Uint8Array(packet));
  186. const startTime = Date.now();
  187. /*
  188. while (true) {
  189. // Check for timeout
  190. if (Date.now() - startTime > timeout) {
  191. //Timeout, ignore this reply
  192. return Promise.reject(new Error('timeout waiting for reply'));
  193. }
  194. // Check if we have enough data for a reply
  195. if (serialReadBuffer.length >= 5) {
  196. // Look for the start of a packet
  197. for (let i = 0; i <= serialReadBuffer.length - 5; i++) {
  198. if (serialReadBuffer[i] === 0x57 && serialReadBuffer[i + 1] === 0xAB) {
  199. //Discard bytes before the packet
  200. if (i > 0) {
  201. serialReadBuffer.splice(0, i);
  202. }
  203. // Now we have 57 AB at the start, check if we have the full packet
  204. const len = serialReadBuffer[3];
  205. const fullPacketLength = 4 + len + 1;
  206. if (serialReadBuffer.length >= fullPacketLength) {
  207. const packet = serialReadBuffer.slice(0, fullPacketLength);
  208. serialReadBuffer = serialReadBuffer.slice(fullPacketLength);
  209. const checksum = this.calcChecksum(packet.slice(0, fullPacketLength - 1));
  210. if (checksum !== packet[fullPacketLength - 1]) {
  211. // Invalid checksum, discard packet
  212. continue;
  213. }
  214. if (packet[4] === replyCmd) {
  215. return Promise.resolve();
  216. }
  217. }
  218. }
  219. }
  220. }
  221. }*/
  222. // Seems the speed required to get a reply is too high for the browser
  223. // so reply check is not implemented for now
  224. await new Promise(resolve => setTimeout(resolve, 30));
  225. return Promise.resolve();
  226. }
  227. // Mouse move absolute
  228. async MouseMoveAbsolute(xLSB, xMSB, yLSB, yMSB) {
  229. if (!serialPort || !serialPort.readable || !serialPort.writable) {
  230. return;
  231. }
  232. const packet = [
  233. 0x57, 0xAB, 0x00, 0x04, 0x07, 0x02,
  234. this.hidState.MouseButtons,
  235. xLSB,
  236. xMSB,
  237. yLSB,
  238. yMSB,
  239. 0x00, // Scroll
  240. 0x00 // Checksum placeholder
  241. ];
  242. packet[12] = this.calcChecksum(packet.slice(0, 12));
  243. await this.sendPacketAndWait(packet, 0x04);
  244. }
  245. // Mouse move relative
  246. async MouseMoveRelative(dx, dy, wheel) {
  247. if (!serialPort || !serialPort.readable || !serialPort.writable) {
  248. return;
  249. }
  250. // Ensure 0x80 is not used
  251. if (dx === 0x80) dx = 0x81;
  252. if (dy === 0x80) dy = 0x81;
  253. const packet = [
  254. 0x57, 0xAB, 0x00, 0x05, 0x05, 0x01,
  255. this.hidState.MouseButtons,
  256. dx,
  257. dy,
  258. wheel,
  259. 0x00 // Checksum placeholder
  260. ];
  261. packet[10] = this.calcChecksum(packet.slice(0, 10));
  262. await this.sendPacketAndWait(packet, 0x05);
  263. }
  264. // Mouse button press
  265. async MouseButtonPress(button) {
  266. switch (button) {
  267. case 0x01: // Left
  268. this.hidState.MouseButtons |= 0x01;
  269. break;
  270. case 0x02: // Right
  271. this.hidState.MouseButtons |= 0x02;
  272. break;
  273. case 0x03: // Middle
  274. this.hidState.MouseButtons |= 0x04;
  275. break;
  276. default:
  277. throw new Error("invalid opcode for mouse button press");
  278. }
  279. await this.MouseMoveRelative(0, 0, 0);
  280. }
  281. // Mouse button release
  282. async MouseButtonRelease(button) {
  283. switch (button) {
  284. case 0x00: // Release all
  285. this.hidState.MouseButtons = 0x00;
  286. break;
  287. case 0x01: // Left
  288. this.hidState.MouseButtons &= ~0x01;
  289. break;
  290. case 0x02: // Right
  291. this.hidState.MouseButtons &= ~0x02;
  292. break;
  293. case 0x03: // Middle
  294. this.hidState.MouseButtons &= ~0x04;
  295. break;
  296. default:
  297. throw new Error("invalid opcode for mouse button release");
  298. }
  299. await this.MouseMoveRelative(0, 0, 0);
  300. }
  301. // Mouse scroll
  302. async MouseScroll(tilt) {
  303. if (tilt === 0) return;
  304. let wheel;
  305. if (tilt < 0) {
  306. wheel = this.Config.ScrollSensitivity;
  307. } else {
  308. wheel = 0xFF - this.Config.ScrollSensitivity;
  309. }
  310. await this.MouseMoveRelative(0, 0, wheel);
  311. }
  312. // --- Keyboard Emulation ---
  313. // Set modifier key (Ctrl, Shift, Alt, GUI)
  314. async SetModifierKey(keycode, isRight) {
  315. const MOD_LCTRL = 0x01, MOD_LSHIFT = 0x02, MOD_LALT = 0x04, MOD_LGUI = 0x08;
  316. const MOD_RCTRL = 0x10, MOD_RSHIFT = 0x20, MOD_RALT = 0x40, MOD_RGUI = 0x80;
  317. let modifierBit = 0;
  318. switch (keycode) {
  319. case 17: modifierBit = isRight ? MOD_RCTRL : MOD_LCTRL; break;
  320. case 16: modifierBit = isRight ? MOD_RSHIFT : MOD_LSHIFT; break;
  321. case 18: modifierBit = isRight ? MOD_RALT : MOD_LALT; break;
  322. case 91: modifierBit = isRight ? MOD_RGUI : MOD_LGUI; break;
  323. default: throw new Error("Not a modifier key");
  324. }
  325. this.hidState.Modkey |= modifierBit;
  326. await this.keyboardSendKeyCombinations();
  327. }
  328. // Unset modifier key (Ctrl, Shift, Alt, GUI)
  329. async UnsetModifierKey(keycode, isRight) {
  330. const MOD_LCTRL = 0x01, MOD_LSHIFT = 0x02, MOD_LALT = 0x04, MOD_LGUI = 0x08;
  331. const MOD_RCTRL = 0x10, MOD_RSHIFT = 0x20, MOD_RALT = 0x40, MOD_RGUI = 0x80;
  332. let modifierBit = 0;
  333. switch (keycode) {
  334. case 17: modifierBit = isRight ? MOD_RCTRL : MOD_LCTRL; break;
  335. case 16: modifierBit = isRight ? MOD_RSHIFT : MOD_LSHIFT; break;
  336. case 18: modifierBit = isRight ? MOD_RALT : MOD_LALT; break;
  337. case 91: modifierBit = isRight ? MOD_RGUI : MOD_LGUI; break;
  338. default: throw new Error("Not a modifier key");
  339. }
  340. this.hidState.Modkey &= ~modifierBit;
  341. await this.keyboardSendKeyCombinations();
  342. }
  343. // Send a keyboard press by JavaScript keycode
  344. async SendKeyboardPress(keycode) {
  345. const hid = this.javaScriptKeycodeToHIDOpcode(keycode);
  346. if (hid === 0x00) throw new Error("Unsupported keycode: " + keycode);
  347. // Already pressed?
  348. for (let i = 0; i < 6; i++) {
  349. if (this.hidState.KeyboardButtons[i] === hid) return;
  350. }
  351. // Find empty slot
  352. for (let i = 0; i < 6; i++) {
  353. if (this.hidState.KeyboardButtons[i] === 0x00) {
  354. this.hidState.KeyboardButtons[i] = hid;
  355. await this.keyboardSendKeyCombinations();
  356. return;
  357. }
  358. }
  359. throw new Error("No space left in keyboard state to press key: " + keycode);
  360. }
  361. // Send a keyboard release by JavaScript keycode
  362. async SendKeyboardRelease(keycode) {
  363. const hid = this.javaScriptKeycodeToHIDOpcode(keycode);
  364. if (hid === 0x00) throw new Error("Unsupported keycode: " + keycode);
  365. for (let i = 0; i < 6; i++) {
  366. if (this.hidState.KeyboardButtons[i] === hid) {
  367. this.hidState.KeyboardButtons[i] = 0x00;
  368. await this.keyboardSendKeyCombinations();
  369. return;
  370. }
  371. }
  372. // Not pressed, do nothing
  373. }
  374. // Send the current key combinations (modifiers + up to 6 keys)
  375. async keyboardSendKeyCombinations() {
  376. const packet = [
  377. 0x57, 0xAB, 0x00, 0x02, 0x08,
  378. this.hidState.Modkey, 0x00,
  379. 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  380. 0x00
  381. ];
  382. for (let i = 0; i < 6; i++) {
  383. packet[7 + i] = this.hidState.KeyboardButtons[i] || 0x00;
  384. }
  385. packet[13] = this.calcChecksum(packet.slice(0, 13));
  386. await this.sendPacketAndWait(packet, 0x02);
  387. }
  388. // Convert JavaScript keycode to HID keycode
  389. javaScriptKeycodeToHIDOpcode(keycode) {
  390. // Letters A-Z
  391. if (keycode >= 65 && keycode <= 90) return (keycode - 65) + 0x04;
  392. // Numbers 1-9 (top row, not numpad)
  393. if (keycode >= 49 && keycode <= 57) return (keycode - 49) + 0x1E;
  394. // F1 to F12
  395. if (keycode >= 112 && keycode <= 123) return (keycode - 112) + 0x3A;
  396. switch (keycode) {
  397. case 8: return 0x2A; // Backspace
  398. case 9: return 0x2B; // Tab
  399. case 13: return 0x28; // Enter
  400. case 16: return 0xE1; // Left shift
  401. case 17: return 0xE0; // Left Ctrl
  402. case 18: return 0xE6; // Left Alt
  403. case 19: return 0x48; // Pause
  404. case 20: return 0x39; // Caps Lock
  405. case 27: return 0x29; // Escape
  406. case 32: return 0x2C; // Spacebar
  407. case 33: return 0x4B; // Page Up
  408. case 34: return 0x4E; // Page Down
  409. case 35: return 0x4D; // End
  410. case 36: return 0x4A; // Home
  411. case 37: return 0x50; // Left Arrow
  412. case 38: return 0x52; // Up Arrow
  413. case 39: return 0x4F; // Right Arrow
  414. case 40: return 0x51; // Down Arrow
  415. case 44: return 0x46; // Print Screen or F13 (Firefox)
  416. case 45: return 0x49; // Insert
  417. case 46: return 0x4C; // Delete
  418. case 48: return 0x27; // 0 (not Numpads)
  419. case 59: return 0x33; // ';'
  420. case 61: return 0x2E; // '='
  421. case 91: return 0xE3; // Left GUI (Windows)
  422. case 92: return 0xE7; // Right GUI
  423. case 93: return 0x65; // Menu key
  424. case 96: return 0x62; // 0 (Numpads)
  425. case 97: return 0x59; // 1 (Numpads)
  426. case 98: return 0x5A; // 2 (Numpads)
  427. case 99: return 0x5B; // 3 (Numpads)
  428. case 100: return 0x5C; // 4 (Numpads)
  429. case 101: return 0x5D; // 5 (Numpads)
  430. case 102: return 0x5E; // 6 (Numpads)
  431. case 103: return 0x5F; // 7 (Numpads)
  432. case 104: return 0x60; // 8 (Numpads)
  433. case 105: return 0x61; // 9 (Numpads)
  434. case 106: return 0x55; // * (Numpads)
  435. case 107: return 0x57; // + (Numpads)
  436. case 109: return 0x56; // - (Numpads)
  437. case 110: return 0x63; // dot (Numpads)
  438. case 111: return 0x54; // divide (Numpads)
  439. case 144: return 0x53; // Num Lock
  440. case 145: return 0x47; // Scroll Lock
  441. case 146: return 0x58; // Numpad enter
  442. case 173: return 0x2D; // -
  443. case 186: return 0x33; // ';'
  444. case 187: return 0x2E; // '='
  445. case 188: return 0x36; // ','
  446. case 189: return 0x2D; // '-'
  447. case 190: return 0x37; // '.'
  448. case 191: return 0x38; // '/'
  449. case 192: return 0x35; // '`'
  450. case 219: return 0x2F; // '['
  451. case 220: return 0x31; // backslash
  452. case 221: return 0x30; // ']'
  453. case 222: return 0x34; // '\''
  454. default: return 0x00;
  455. }
  456. }
  457. }
  458. // Instantiate HID controller
  459. const controller = new HIDController();
  460. const videoOverlayElement = document.getElementById('touchscreen');
  461. let isMouseDown = false;
  462. let lastX = 0;
  463. let lastY = 0;
  464. // Mouse down
  465. videoOverlayElement.addEventListener('mousedown', async (e) => {
  466. isMouseDown = true;
  467. lastX = e.clientX;
  468. lastY = e.clientY;
  469. if (e.button === 0) {
  470. await controller.MouseButtonPress(0x01); // Left
  471. } else if (e.button === 2) {
  472. await controller.MouseButtonPress(0x02); // Right
  473. } else if (e.button === 1) {
  474. await controller.MouseButtonPress(0x03); // Middle
  475. }
  476. });
  477. // Mouse up
  478. videoOverlayElement.addEventListener('mouseup', async (e) => {
  479. isMouseDown = false;
  480. if (e.button === 0) {
  481. await controller.MouseButtonRelease(0x01); // Left
  482. } else if (e.button === 2) {
  483. await controller.MouseButtonRelease(0x02); // Right
  484. } else if (e.button === 1) {
  485. await controller.MouseButtonRelease(0x03); // Middle
  486. }
  487. });
  488. // Mouse move (absolute positioning)
  489. videoOverlayElement.addEventListener('mousemove', async (e) => {
  490. const rect = videoOverlayElement.getBoundingClientRect();
  491. const x = e.clientX - rect.left;
  492. const y = e.clientY - rect.top;
  493. const width = rect.width;
  494. const height = rect.height;
  495. const offsetX = x / width;
  496. const offsetY = y / height;
  497. //console.log('Offset ratio:', { offsetX, offsetY });
  498. const absX = Math.round(offsetX * 4095);
  499. const absY = Math.round(offsetY * 4095);
  500. await controller.MouseMoveAbsolute(absX & 0xFF, (absX >> 8) & 0xFF, absY & 0xFF, (absY >> 8) & 0xFF);
  501. });
  502. // Context menu disable (for right click)
  503. videoOverlayElement.addEventListener('contextmenu', (e) => {
  504. e.preventDefault();
  505. });
  506. // Mouse wheel (scroll)
  507. videoOverlayElement.addEventListener('wheel', async (e) => {
  508. e.preventDefault();
  509. let tilt = e.deltaY > 0 ? 1 : -1;
  510. await controller.MouseScroll(tilt);
  511. });
  512. // Keyboard events for HID emulation
  513. window.addEventListener('keydown', async (e) => {
  514. // Ignore repeated events
  515. //if (e.repeat) return;
  516. try {
  517. // Modifier keys
  518. if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') {
  519. await controller.SetModifierKey(e.keyCode, e.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT);
  520. } else {
  521. await controller.SendKeyboardPress(e.keyCode);
  522. }
  523. e.preventDefault();
  524. } catch (err) {
  525. // Ignore unsupported keys
  526. }
  527. });
  528. window.addEventListener('keyup', async (e) => {
  529. try {
  530. if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') {
  531. await controller.UnsetModifierKey(e.keyCode, e.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT);
  532. } else {
  533. await controller.SendKeyboardRelease(e.keyCode);
  534. }
  535. e.preventDefault();
  536. } catch (err) {
  537. // Ignore unsupported keys
  538. }
  539. });
  540. document.getElementById('resetHIDBtn').addEventListener('click', async () => {
  541. try {
  542. await controller.softReset();
  543. alert('HID soft reset sent.');
  544. } catch (e) {
  545. alert('Failed to reset HID: ' + e.message);
  546. }
  547. });
  548. /*
  549. Audio Capture
  550. */
  551. const audioSelect = document.getElementById('audioSource');
  552. const refreshAudioBtn = document.getElementById('refreshAudioSources');
  553. let currentAudioStream = null;
  554. // List audio input devices
  555. async function listAudioSources() {
  556. const devices = await navigator.mediaDevices.enumerateDevices();
  557. audioSelect.innerHTML = '';
  558. devices
  559. .filter(device => device.kind === 'audioinput')
  560. .forEach(device => {
  561. const option = document.createElement('option');
  562. option.value = device.deviceId;
  563. option.text = device.label || `Microphone ${audioSelect.length + 1}`;
  564. audioSelect.appendChild(option);
  565. });
  566. }
  567. // Start streaming selected audio source
  568. async function startAudioStream() {
  569. if (currentAudioStream) {
  570. currentAudioStream.getTracks().forEach(track => track.stop());
  571. }
  572. const deviceId = audioSelect.value;
  573. try {
  574. const stream = await navigator.mediaDevices.getUserMedia({
  575. audio: {
  576. deviceId: deviceId ? { exact: deviceId } : undefined,
  577. echoCancellation: false,
  578. noiseSuppression: false,
  579. autoGainControl: false,
  580. sampleRate: 48000,
  581. channelCount: 2
  582. }
  583. });
  584. currentAudioStream = stream;
  585. // Create audio element if not exists
  586. let audioElem = document.getElementById('audioStream');
  587. if (!audioElem) {
  588. audioElem = document.createElement('audio');
  589. audioElem.id = 'audioStream';
  590. audioElem.autoplay = true;
  591. audioElem.controls = true;
  592. audioElem.style.position = 'fixed';
  593. audioElem.style.bottom = '10px';
  594. audioElem.style.left = '10px';
  595. audioElem.style.zIndex = 1001;
  596. document.body.appendChild(audioElem);
  597. audioElem.style.display = 'none';
  598. }
  599. audioElem.srcObject = stream;
  600. } catch (err) {
  601. alert('Error accessing audio device: ' + err.message);
  602. }
  603. }
  604. // Event listeners
  605. refreshAudioBtn.addEventListener('click', listAudioSources);
  606. audioSelect.addEventListener('change', startAudioStream);
  607. // Initial population
  608. listAudioSources().then(startAudioStream);
  609. /*
  610. Video Captures
  611. The following section handles HDMI capture via connected webcams.
  612. */
  613. async function ensureCameraPermission() {
  614. try {
  615. // Request permission to access the camera to get device labels
  616. await navigator.mediaDevices.getUserMedia({ video: true });
  617. } catch (e) {
  618. alert('Unable to access camera');
  619. }
  620. }
  621. async function getCameras() {
  622. const devices = await navigator.mediaDevices.enumerateDevices();
  623. const videoSelect = document.getElementById('videoSource');
  624. videoSelect.innerHTML = '';
  625. devices.forEach(device => {
  626. if (device.kind === 'videoinput') {
  627. const option = document.createElement('option');
  628. option.value = device.deviceId;
  629. option.text = device.label || `Camera ${videoSelect.length + 1}`;
  630. videoSelect.appendChild(option);
  631. }
  632. });
  633. }
  634. async function startStream() {
  635. const videoSelect = document.getElementById('videoSource');
  636. const deviceId = videoSelect.value;
  637. if (window.currentStream) {
  638. window.currentStream.getTracks().forEach(track => track.stop());
  639. }
  640. const constraints = {
  641. video: {
  642. deviceId: { exact: deviceId },
  643. width: { ideal: 1920 },
  644. height: { ideal: 1080 }
  645. }
  646. };
  647. try {
  648. const stream = await navigator.mediaDevices.getUserMedia(constraints);
  649. document.getElementById('video').srcObject = stream;
  650. window.currentStream = stream;
  651. // Resize touchscreen overlay after a short delay to ensure video is loaded
  652. setTimeout(resizeTouchscreenToVideo, 500);
  653. } catch (e) {
  654. alert('Unable to access camera');
  655. }
  656. }
  657. function getResolutionFromCurrentStream() {
  658. if (window.currentStream) {
  659. const track = window.currentStream.getVideoTracks()[0];
  660. const settings = track.getSettings();
  661. return { width: settings.width, height: settings.height };
  662. }
  663. return null;
  664. }
  665. document.getElementById('videoSource').addEventListener('change', startStream);
  666. document.getElementById('fullscreenBtn').addEventListener('click', () => {
  667. if (
  668. document.fullscreenElement ||
  669. document.webkitFullscreenElement ||
  670. document.mozFullScreenElement ||
  671. document.msFullscreenElement
  672. ) {
  673. if (document.exitFullscreen) {
  674. document.exitFullscreen();
  675. } else if (document.webkitExitFullscreen) {
  676. document.webkitExitFullscreen();
  677. } else if (document.mozCancelFullScreen) {
  678. document.mozCancelFullScreen();
  679. } else if (document.msExitFullscreen) {
  680. document.msExitFullscreen();
  681. }
  682. document.querySelector('#fullscreenBtn span').className = 'ts-icon is-maximize-icon';
  683. } else {
  684. if (document.body.requestFullscreen) {
  685. document.body.requestFullscreen();
  686. } else if (document.body.webkitRequestFullscreen) {
  687. document.body.webkitRequestFullscreen();
  688. } else if (document.body.mozRequestFullScreen) {
  689. document.body.mozRequestFullScreen();
  690. } else if (document.body.msRequestFullscreen) {
  691. document.body.msRequestFullscreen();
  692. }
  693. document.querySelector('#fullscreenBtn span').className = 'ts-icon is-minimize-icon';
  694. }
  695. });
  696. document.getElementById('refreshCameras').addEventListener('click', async () => {
  697. await getCameras();
  698. await startStream();
  699. });
  700. // Ensure permission, then populate cameras and start stream
  701. ensureCameraPermission().then(() => {
  702. getCameras().then(startStream);
  703. });
  704. navigator.mediaDevices.addEventListener('devicechange', () => {
  705. getCameras().then(startStream);
  706. });