commit 3b577387df8729887210caebb5f977cac0a06089 Author: bmeow Date: Sat Aug 3 18:32:21 2024 +0800 feat: read serial port and play video according to tag id diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b2ca69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# config +/tags.js +/video/* +!/video/README.md + +# dev +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1241b7 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# usr-tag-interaction +## Usage +1. Put video files in `video/` directory +2. Copy `tags.example.js` to `tags.js` and edit it + to configure the tag ids and the corresponding video urls +3. Open `index.html` with [browser that supports Web Serial API](https://caniuse.com/?search=serial) + (Chrome, Edge, Opera as of 2024/08/03) diff --git a/index.html b/index.html new file mode 100644 index 0000000..c496ef6 --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ + + + + + +
Click me!
+
+ + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..358a4f9 --- /dev/null +++ b/index.js @@ -0,0 +1,155 @@ +/** Video container element */ +const videoRoot = /** @type {HTMLElement} */ + (document.getElementById('video-root')); + +/** + * A map from tag id to video element + * {[$tagId]: <video><source src="$src"/></video>} + */ +const videos = Object.fromEntries(Object.entries(tags).map(([tagId, src]) => { + const video = document.createElement('video'); + const source = document.createElement('source'); + source.setAttribute('src', src); + video.appendChild(source); + videoRoot.appendChild(video); + return [tagId, video]; +})); + +/** + * Current playing video element + * @type {HTMLVideoElement|null} + */ +let currentVideo = null; + +/** + * Callback function when a tag is read + * @param {string} tagId + */ +function onTag(tagId) { + const nextVideo = videos[tagId]; + // ignore unknown tag + if (nextVideo == null) { + console.log('unknown tag', tagId); + return; + } + // ignore if the same tag is used + if (nextVideo === currentVideo) return; + // play the next video from the beginning + nextVideo.currentTime = 0; + nextVideo.play(); + nextVideo.classList.add('playing'); + // stop the current video + currentVideo?.pause(); + currentVideo?.classList.remove('playing'); + // update video reference + currentVideo = nextVideo; +} + +/** + * Open and read the serial port + * @param {SerialPort} port + */ +async function openPort(port, baudRate=9600) { + // open the serial port + await port.open({baudRate}); + // reader of byte stream from the serial port + const reader = port.readable.getReader({mode: 'byob'}); + + // buffer for reading the serial port + const bufferSize = 16; + let buffer = new ArrayBuffer(bufferSize); + let offset = 0; + try { + while (true) { + // read bytes from the serial port to buffer + const {value, done} = await reader.read( + new Uint8Array(buffer, offset, bufferSize - offset), + ); + + // no more message + if (done) { + showMessage('The serial port is closed'); + break; + } + + // update buffer and offset + buffer = value.buffer; + offset += value.byteLength; + + // message format: + // - (u8) N = tag id length + // - (u8[N]) tag id + /** pointer (array index) to the message in buffer */ + let ptr = 0; + // if tag id length of the next message presents + while (offset > ptr) { + const tagIdLength = new DataView(buffer).getUint8(0); + // break if the message is not completely read + const ptrNext = ptr + 1 + tagIdLength; + if (offset < ptrNext) break; + // convert tag id bytes to HEX string + const tagId = Array.from( + new Uint8Array(buffer, ptr + 1, tagIdLength), + // %02X + x => x.toString(16).toUpperCase().padStart(2, '0'), + ).join(''); + // handle the tag + onTag(tagId); + // try to read the next message + ptr = ptrNext; + } + // move the remaining bytes in the buffer to the beginning + // if any message is read + if (ptr > 0) { + // remaining byte count + const remainingLength = offset - ptr; + // copy bytes + if (remainingLength > 0) { + new Uint8Array(buffer) + .set(new Uint8Array(buffer, ptr, remainingLength)); + } + // update offset + offset = remainingLength; + } + } + } catch (err) { + showMessage( + 'Error occurred while reading serial port:\n' + + (err?.toString() ?? ''), + ); + console.error(err); + } finally { + reader.releaseLock(); + } +} + +/** HTML element for showing message */ +const msgElm = /** @type {HTMLElement} */ (document.getElementById('message')); +/** Show message on page */ +const showMessage = (/** @type {string} */ msg) => msgElm.textContent = msg; + +// check if this browser supports Web Serial API +if (navigator.serial == null) { + showMessage('Your browser does not support Web Serial API!'); +} else { + // click body to select serial port to listen + // this is required since videos cannot be played without user's interaction + document.body.addEventListener('click', async function onBodyClicked() { + // get serial port + try { + // try to get serial ports + const ports = await navigator.serial.getPorts(); + // choose the first port (FIXME) if availble port presents + // or request user to select a port + const port = ports[0] ?? await navigator.serial.requestPort(); + // unregister body onClick listener + document.body.removeEventListener('click', onBodyClicked); + // remove message from page + showMessage(''); + // start handling the messages from the serial port + openPort(port); + } catch(err) { + showMessage('Failed to access serial port:\n' + (err?.toString() ?? '')); + } + }); +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..651b470 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "lib": ["dom", "es2020"], + "checkJs": true, + "strict": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..969dce6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "usr-tag-interaction", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "usr-tag-interaction", + "devDependencies": { + "@types/dom-serial": "^1.0.6" + } + }, + "node_modules/.pnpm/@types+dom-serial@1.0.6/node_modules/@types/dom-serial": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dom-serial": { + "resolved": "node_modules/.pnpm/@types+dom-serial@1.0.6/node_modules/@types/dom-serial", + "link": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..06bd1ba --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "usr-tag-interaction", + "devDependencies": { + "@types/dom-serial": "^1.0.6" + } +} diff --git a/tags.example.js b/tags.example.js new file mode 100644 index 0000000..5b7cea7 --- /dev/null +++ b/tags.example.js @@ -0,0 +1,9 @@ +/** + * A map from tag id (HEX string) to url of the corresponding video + * @type {{[tagId: string]: string}} + */ +var tags = { + '3E033E03': 'video/CAT1.mp4', + 'ABCD1234': 'video/CAT2.mp4', + 'BEEFCAFE': 'video/CAT3.mp4', +}; diff --git a/video/README.md b/video/README.md new file mode 100644 index 0000000..b96d477 --- /dev/null +++ b/video/README.md @@ -0,0 +1 @@ +# Put your video files here!