[
  {
    "path": ".gitignore",
    "content": "node_modules/\nbuild/\ncert/\ndeploy\n\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2021-2022 James Deery\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "# Copyright 2021-2022 James Deery\n# Released under the MIT licence, https://opensource.org/licenses/MIT\n\nDEPLOY = \\\n\tbuild/index.html \\\n\tbuild/worker.js \\\n\tapp.webmanifest \\\n\tbuild/icon.png\n\nCACHE_FILES = \\\n\tbuild/index.html \\\n\tbuild/icon.png \\\n\tapp.webmanifest\n\nDEPLOY_DIR = deploy/\n\nNPM = node_modules/\n\nifeq ($(shell uname -s),Darwin)\n\tBASE64 = base64 -i\n\tMD5 = md5\nelse\n\tBASE64 = base64 -w0\n\tMD5 = md5sum\nendif\n\n\nindex.html: build/index.css js/main.js\n\njs/main.js: $(filter-out js/main.js,$(wildcard js/*.js)) build/shaders.js build/font1.js build/font2.js\n\t@touch $@\n\nbuild/shaders.js: $(wildcard shaders/*.vert) $(wildcard shaders/*.frag)\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@for i in $^; do \\\n\t  printf \"export const $$(basename $${i} | tr . _) = \\`\"; \\\n\t  sed 's/\\/\\/.*$$//g' $$i \\\n\t   | perl -0pe 's/([\\n;,{}()\\[\\]=+\\-*\\/])[ \\t\\r\\n]+/$$1/g'; \\\n\t  echo \"\\`;\"; \\\n\tdone > $@\n\nbuild/font1.js: font1.png\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@printf \"export const font1 = 'data:image/png;base64,$$($(BASE64) $^)';\" > $@\n\nbuild/font2.js: font2.png\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@printf \"export const font2 = 'data:image/png;base64,$$($(BASE64) $^)';\" > $@\n\nbuild/main.js: js/main.js $(NPM)\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@npx rollup $< \\\n\t  | npx terser --mangle --toplevel --compress > $@\n\nbuild/worker.js: js/worker.js $(CACHE_FILES) $(NPM)\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@sed \"s/INDEXHASH/$$(cat $(CACHE_FILES) | $(MD5))/\" $< \\\n\t  | npx terser --mangle --compress > $@\n\ncss/index.scss: $(filter-out css/index.scss,$(wildcard css/*.scss)) build/font1.scss build/font2.scss\n\t@touch $@\n\nbuild/font1.scss: m8stealth57.woff2\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@printf \"@font-face {\\n\\\n\t    font-family: 'm8stealth57';\\n\\\n\t    src: url('data:font/woff2;base64,$$($(BASE64) $^)') format('woff2');\\n\\\n\t}\" > $@\n\nbuild/font2.scss: m8stealth89.woff2\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@printf \"@font-face {\\n\\\n\t    font-family: 'm8stealth89';\\n\\\n\t    src: url('data:font/woff2;base64,$$($(BASE64) $^)') format('woff2');\\n\\\n\t}\" > $@\n\nbuild/index.css: css/index.scss $(NPM)\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@npx sass --style=compressed $< > $@\n\nbuild/index.html: index.html build/index.css build/main.js favicon.png $(NPM)\n\t@echo Building $@\n\t@mkdir -p $(@D)\n\t@sed \"s/BUILDNUM/$$(date -u +\"%Y-%m-%dT%H:%M:%S\") $$(git rev-parse --short HEAD)$$(test -z \"$$(git status --porcelain)\" || printf X)/\" $< \\\n\t | sed -e 's/\"build\\/index.css\"/\"index.css\"/' \\\n\t | sed -e 's/\"js\\/main.js\"/\"main.js\"/' \\\n\t | sed -e 's|\"favicon.png\"|\"data:image/png;base64,'$$($(BASE64) favicon.png)'\"|' \\\n\t | sed -e 's/^ *//' \\\n\t | perl -0pe 's/>[ \\t\\r\\n]+</></g' > $@.tmp\n\t@npx juice \\\n\t  --apply-style-tags false \\\n\t  --remove-style-tags false \\\n\t  $@.tmp $@\n\t@rm $@.tmp\n\nbuild/icon.png: icon.png\n\t@echo Building $@\n\t@cp $< $@\n\ncert/cert.conf: $(NPM)\n\t@echo Building $@\n\t@mkdir -p cert\n\t@echo \"[req]\\n\\\n\tdistinguished_name=dn\\n\\\n\treq_extensions=ext\\n\\\n\tprompt=no\\n\\\n\t[dn]\\n\\\n\tCN=DevCert\\n\\\n\tOU=DEV\\n\\\n\t[ext]\\n\\\n\tkeyUsage=nonRepudiation,digitalSignature,keyEncipherment\\n\\\n\tbasicConstraints=critical,CA:TRUE,pathlen:0\\n\\\n\tsubjectAltName=DNS:localhost,$$(\\\n\t  npx ws --list-network-interfaces \\\n\t   | grep '^-' \\\n\t   | sed -E 's/^- .+: ([0-9.]+)$$/IP:\\1/g' \\\n\t   | sed -E 's/^- .+: (.+)$$/DNS:\\1/g' \\\n\t   | paste -sd ',' -)\" > $@\n\ncert/private-key.pem:\n\t@echo Building $@\n\t@mkdir -p cert\n\t@openssl genrsa -out $@ 2048\n\ncert/server.csr: cert/private-key.pem cert/cert.conf\n\t@echo Building $@\n\t@openssl req \\-new \\\n\t  -nodes \\\n\t  -sha256 \\\n\t  -key cert/private-key.pem \\\n\t  -config cert/cert.conf \\\n\t  -out $@\n\ncert/server.crt: cert/private-key.pem cert/cert.conf cert/server.csr\n\t@echo Building $@\n\t@openssl x509 \\\n\t  -req \\\n\t  -sha256 \\\n\t  -days 90 \\\n\t  -in cert/server.csr \\\n\t  -signkey cert/private-key.pem \\\n\t  -extfile cert/cert.conf \\\n\t  -extensions ext \\\n\t  -out $@\n\n$(NPM):\n\t@echo Installing node packages\n\t@npm ci\n\nall: $(DEPLOY)\n\nclean:\n\t@echo Cleaning\n\t@rm -r build/*\n\nifeq ($(HTTPS),true)\nrun: index.html cert/private-key.pem cert/server.crt $(NPM)\n\t@npx ws \\\n\t\t--log.format dev \\\n\t\t--rewrite '/worker.js -> /js/worker.js' \\\n\t\t--blacklist /cert/private-key.pem \\\n\t\t--key cert/private-key.pem \\\n\t\t--cert cert/server.crt\nelse\nrun: index.html $(NPM)\n\t@npx ws \\\n\t\t--log.format dev \\\n\t\t--rewrite '/worker.js -> /js/worker.js' \\\n\t\t--blacklist /cert/private-key.pem\nendif\n\ndeploy: $(DEPLOY)\n\t@echo Deploying\n\t@mkdir -p $(DEPLOY_DIR)\n\t@rm -rf $(DEPLOY_DIR)/*\n\t@cp $^ $(DEPLOY_DIR)\n\n.PHONY: all run deploy clean\n"
  },
  {
    "path": "README.md",
    "content": "# M8 Headless Web Display\n\nThis is alternative frontend for [M8 Headless](https://github.com/DirtyWave/M8HeadlessFirmware).\n\nIt runs entirely in the browser and only needs to be hosted on a server to satisfy browser security policies. No network communication is involved.\n\nTry it out at https://derkyjadex.github.io/M8WebDisplay/.\n\nFeatures:\n\n- Render the M8 display\n- Route M8's audio out to the default audio output\n- Keyboard and gamepad input\n- Custom key/button mapping\n- Touch-compatible on-screen keys\n- Firmware loader\n- Full offline support\n- Installable as a [PWA](https://en.wikipedia.org/wiki/Progressive_web_application)\n\n## Supported Platforms\n\nThe following should generally work, details are below.\n\n- Chrome 89+ on macOS, Windows and Linux<sup>1</sup>\n- Edge 89+ on macOS and Windows\n- Chrome on Android<sup>2</sup>, without audio<sup>3</sup>\n\nThe web display uses the Web Serial API to communicate with the M8. This API is currently only supported by desktop versions of Google Chrome and Microsoft Edge in versions 89 or later. For Chrome on Android the code can fallback to using the WebUSB API.\n\n1. On Ubuntu and Debian systems (and perhaps others) users do not have permission to access the M8's serial port by default. You will need to add yourself to the `dialout` group and restart your login session/reboot. After this you should be able to connect normally.\n2. Newer Samsung phones appear to handle USB serial in a way that prevents Chrome from being able to open the device. There is an [outstanding Chromium bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1099521#c21) to fix this.\n3. The way that that Android handles USB audio devices (such as the M8) prevents us from being able to redirect the audio to the phone's speakers or headphone output. When the M8 is attached, Android appears to completely disable the internal audio interface and uses the M8 for all audio input and output instead. So the page is able to receive the audio from the M8 but it does not have anywhere to redirect it to other than the M8 itself.\n\n## Developing\n\nTo build this project you need a standard unix-like environment and a recent-ish version of [Node.js](https://nodejs.org/) (15.6 works, earlier versions might not). You should be able to build on macOS, Linux and [WSL](https://docs.microsoft.com/en-us/windows/wsl/) on Windows.\n\nFrom a fresh clone, run this in your terminal:\n\n```\nmake run\n```\n\nThis will download the necessary node packages, build the files required to run a debug version of the display and launch a local web server. If this is successful you can open http://localhost:8000/ in Chrome to launch the display. Press `ctrl-c` to stop the server.\n\nYou can edit the `*.js` files and simply refresh the page to see the changes. If you edit the `*.scss` files or the shaders you will need to run `make` to regenerate the necessary files before refreshing. You can do this from another terminal window/tab, there is no need to restart the server.\n\nChrome requires that pages are served securely in order to enable features such as the Serial API. Normally this means using HTTPS but there is an exception when you use `localhost`. If you want to test your changes on another computer on your network you will need to run the local web server with HTTPS:\n\n```\nmake run HTTPS=true\n```\n\nThis will generate a certificate and the local web server will now work from `https://<your-computer-name>:8000` (the full list of addresses is shown in the command output). When you use this address you will need to either ignore the security warning or install the certificate at `cert/server.crt` as a trusted Certificate Authority on your device.\n\nTo build a release version of the display run:\n\n```\nmake deploy\n```\n\nThis will build and copy the release files to the `deploy/` directory. These files can be hosted on any static web server as long as has an HTTPS address.\n\n## TODO/Ideas\n\n- Avoid/automatically recover from bad frames\n- Auto-reboot for firmware loader/real M8 support\n- Selectable audio output device\n\n## Licence\n\nThis code is released under the MIT licence.\n\nSee LICENSE for more details.\n"
  },
  {
    "path": "app.webmanifest",
    "content": "{\n  \"name\": \"M8 Display\",\n  \"short_name\": \"M8 Display\",\n  \"description\": \"Web display for M8 Headless\",\n  \"start_url\": \".\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#000\",\n  \"theme_color\": \"#000\",\n  \"icons\": [\n    {\n      \"src\": \"icon.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any maskable\"\n    }\n  ]\n}\n"
  },
  {
    "path": "css/common.scss",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n$m8: #00efff;\n$m8-font: 'm8stealth57';\n$m8-font-big: 'm8stealth89';\n$shield-z: 1000;\n\n"
  },
  {
    "path": "css/display.scss",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *;\n\n#display {\n    position: relative;\n    width: 100vw;\n    height: calc(100vw * 3 / 4);\n    max-width: calc(100vh * 4 / 3);\n    max-height: 100vh;\n    margin: 0 auto;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    user-select: none;\n\n    canvas, svg {\n        position: absolute;\n        image-rendering: pixelated;\n        width: 100%;\n        height: 100%;\n    }\n\n    svg text {\n        font-family: $m8-font;\n        font-size: 16px;\n    }\n\n    svg.big text {\n        font-family: $m8-font-big;\n        font-size: 24px;\n    }\n\n    #buttons {\n        position: absolute;\n        top: 25px;\n        width: 100%;\n        text-align: center;\n\n        body.mapping & {\n            display: none;\n        }\n    }\n\n    #mapping-buttons {\n        display: none;\n        position: absolute;\n        top: 25px;\n        width: 100%;\n        text-align: center;\n\n        body.mapping & {\n            display: block;\n        }\n\n        button {\n            margin: 0 5px 10px;\n        }\n    }\n\n    &.with-controls, body.mapping & {\n        height: calc(100vw * 6 / 4);\n        max-width: calc(100vh * 4 / 6);\n\n        canvas, svg {\n            height: 50%;\n        }\n\n        #controls {\n            display: block;\n        }\n    }\n\n    #controls {\n        display: none;\n        position: relative;\n        width: 100%;\n        height: 50%;\n        top: 50%;\n\n        > div {\n            position: absolute;\n            width: 20.3125%;\n            height: 27.0833%;\n            border: 3px solid #aaa;\n            border-radius: 10px;\n            background-color: #333;\n            box-sizing: border-box;\n            padding: 5px;\n            text-align: center;\n            font-size: 80%;\n            transition-property: border-color, background-color;\n            transition-duration: 200ms;\n\n            &.active {\n                border-color: #0cf;\n                background-color: #0cf;\n                transition-duration: 0s;\n\n                &[data-action=\"edit\"] {\n                    border-color: #d48;\n                    background-color: #d48;\n                }\n\n                &[data-action=\"option\"] {\n                    border-color: #66c;\n                    background-color: #66c;\n                }\n\n                &[data-action=\"select\"] {\n                    border-color: #d73;\n                    background-color: #d73;\n                }\n\n                &[data-action=\"start\"] {\n                    border-color: #5a3;\n                    background-color: #5a3;\n                }\n            }\n\n            &.mapping {\n                border-color: #e30;\n                z-index: $shield-z + 1;\n            }\n        }\n    }\n\n    #mapping-help {\n        display: none;\n        position: absolute;\n        bottom: 50%;\n        width: 100%;\n        font-family: $m8-font;\n        text-align: center;\n\n        body.mapping & {\n            display: block;\n\n            .select-action {\n                display: block;\n            }\n            .enter-input {\n                display: none;\n            }\n        }\n\n        body.mapping.capturing & {\n            .select-action {\n                display: none;\n            }\n            .enter-input {\n                display: block;\n                position: relative;\n                z-index: $shield-z + 1;\n            }\n        }\n    }\n}\n\n#capture-shield {\n    display: none;\n    position: fixed;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    background-color: rgba(0, 0, 0, 0.75);\n    z-index: $shield-z;\n\n    body.mapping.capturing & {\n        display: block;\n    }\n}\n"
  },
  {
    "path": "css/firmware.scss",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *;\n\n#firmware {\n    position: fixed;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    padding: 100px;\n    background-color: rgba(0, 0, 0, 0.75);\n    text-align: center;\n    font-family: $m8-font;\n    line-height: 1.5;\n\n    &.hidden {\n        display: none;\n    }\n\n    p.select-file-msg,\n    input[type=file],\n    p.file-loading-msg,\n    p.file-error-msg,\n    p.select-device-msg,\n    button.select-device,\n    p.device-error-msg,\n    p.flash-msg,\n    button.flash,\n    p.flashing-msg,\n    progress.flash,\n    p.flash-error-msg,\n    p.flash-success-msg {\n        display: none;\n        margin: 20px auto;\n    }\n\n    p.file-error-msg,\n    p.device-error-msg,\n    p.flash-error-msg {\n        color: #e22;\n    }\n\n    &.file-select {\n        p.select-file-msg,\n        input[type=file] {\n            display: block;\n        }\n    }\n\n    &.file-loading {\n        p.file-loading-msg {\n            display: block;\n        }\n\n        button.close {\n            display: none;\n        }\n    }\n\n    &.file-error {\n        p.select-file-msg,\n        input[type=file],\n        p.file-error-msg {\n            display: block;\n        }\n    }\n\n    &.file-loaded {\n        p.select-device-msg,\n        button.select-device {\n            display: block;\n        }\n    }\n\n    &.device-error {\n        p.select-device-msg,\n        button.select-device,\n        p.device-error-msg {\n            display: block;\n        }\n    }\n\n    &.device-selected {\n        p.flash-msg,\n        button.flash {\n            display: block;\n        }\n    }\n\n    &.flashing {\n        p.flashing-msg,\n        progress.flash {\n            display: block;\n        }\n\n        button.close {\n            display: none;\n        }\n    }\n\n    &.flash-error {\n        p.select-device-msg,\n        button.select-device,\n        p.flash-error-msg {\n            display: block;\n        }\n    }\n\n    &.flash-success {\n        p.flash-success-msg {\n            display: block;\n        }\n    }\n\n    button.close {\n        display: block;\n        position: absolute;\n        right: 100px;\n        bottom: 100px;\n    }\n}\n"
  },
  {
    "path": "css/form.scss",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *;\n\n$menu-time: 80ms;\n\nlabel, input, select, button {\n    appearance: none;\n    font-family: $m8-font;\n    font-size: 14px;\n    background-color: #000;\n    color: #fff;\n\n    &:hover {\n        background-color: $m8;\n        color: #000;\n    }\n\n    &:active {\n        background-color: darken($m8, 10%);\n        color: #fff;\n    }\n\n    &:focus, &:focus-within {\n        outline: none;\n        color: $m8;\n\n        &:hover {\n            color: #000;\n        }\n\n        &:active {\n            color: #fff;\n        }\n    }\n}\n\nbutton {\n    border: 3px solid #fff;\n    padding: 20px;\n}\n\ninput[type=checkbox] {\n    font-family: $m8-font;\n\n    &::after {\n        content: 'OFF';\n    }\n\n    &:checked::after {\n        content: 'ON';\n    }\n}\n\ninput[type=file] {\n    border: 3px solid #fff;\n    padding: 20px;\n}\n\nselect {\n    padding: 5px 32px 5px 5px;\n    background-color: transparent;\n    background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='3' d='M2 5l6 6 6-6'/%3e%3c/svg%3e\");\n    background-repeat: no-repeat;\n    background-position: right 10px center;\n    background-size: 16px;\n\n    font-family: sans-serif;\n    font-size: 12px;\n}\n\nprogress {\n    border: 3px solid #fff;\n    height: 40px;\n    width: 100%;\n\n    &::-webkit-progress-bar {\n        background-color: #000;\n    }\n\n    &::-webkit-progress-value {\n        background-color: $m8;\n    }\n}\n"
  },
  {
    "path": "css/index.scss",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *;\n@use '../build/font1';\n@use '../build/font2';\n\nbody {\n    font-family: sans-serif;\n    background-color: #000;\n    color: #eee;\n    margin: 0px;\n    font-size: 16px;\n}\n\na, a:visited {\n    color: $m8;\n    text-decoration: none;\n\n    &:hover {\n        text-decoration: underline;\n    }\n\n    &:focus {\n        outline: 1px solid $m8;\n    }\n}\n\nkbd {\n    font-family: sans-serif;\n    background-color: #444;\n    color: $m8;\n    font-size: 16px;\n    margin: 0 2px;\n    padding: 2px 5px;\n    border-radius: 3px;\n}\n\n.hidden {\n    display: none;\n}\n\n.build {\n    color: #888;\n}\n\n@import 'form';\n@import 'display';\n@import 'settings';\n@import 'firmware';\n\n#info {\n    position: absolute;\n    top: 100px;\n    left: 100px;\n    right: 100px;\n    padding: 20px;\n    background-color: rgba(0, 0, 0, 0.75);\n    text-align: center;\n    line-height: 1.5;\n\n    h1 {\n        font-family: $m8-font;\n        font-size: 32px;\n        margin: 0 0 16px;\n    }\n}\n\n.error {\n    position: absolute;\n    top: 100px;\n    left: 100px;\n    right: 100px;\n    border: 3px solid #fff;\n    padding: 20px;\n    background-color: #a22;\n    user-select: text;\n\n    p {\n        margin-top: 0;\n    }\n\n    p:last-child {\n        margin-bottom: 0;\n    }\n}\n\n#reload {\n    position: fixed;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    text-align: center;\n\n    button {\n        border-bottom-width: 0;\n    }\n}\n"
  },
  {
    "path": "css/settings.scss",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\n@use 'common' as *;\n\n#settings {\n    position: fixed;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    background-color: rgba(0, 0, 0, 0.5);\n    transition: background-color $menu-time linear 0s;\n    user-select: none;\n\n    &.hidden {\n        display: block;\n        width: 0;\n        height: 0;\n        background-color: rgba(0, 0, 0, 0);\n\n        .setting {\n            visibility: hidden;\n            margin-left: -323px;\n            transition: none;\n        }\n\n        #menu-button {\n            background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='1.5' d='M2 4h12M2 8h12M2 12h12'/%3e%3c/svg%3e\");\n        }\n    }\n\n    &.hidden.auto-hide {\n        #menu-button {\n            opacity: 0%;\n            transition: opacity 0.8s linear 0.8s;\n\n            &:hover, &:focus {\n                opacity: 100%;\n                transition: opacity 0s;\n            }\n        }\n    }\n\n    #menu-button {\n        width: 32px;\n        height: 32px;\n        background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-width='1.5' d='M3 3l10 10M3 13l10-10'/%3e%3c/svg%3e\");\n        background-repeat: no-repeat;\n        background-color: transparent;\n        border-color: transparent;\n        font-size: 0;\n        margin-bottom: -3px;\n\n        &:hover {\n            background-color: $m8;\n        }\n\n        &:active {\n            background-color: darken($m8, 10%);\n            color: #fff;\n        }\n\n        &:focus {\n            color: $m8;\n            border-color: $m8;\n\n            &:hover {\n                color: #000;\n            }\n\n            &:active {\n                color: #fff;\n            }\n        }\n    }\n\n    .setting {\n        width: 320px;\n        border: 3px solid #fff;\n        border-width: 3px 3px 0 0;\n        margin-left: 0;\n        transition: margin-left $menu-time linear 0s;\n\n        &:last-child {\n            border-bottom-width: 3px;\n        }\n\n        label {\n            display: block;\n            position: relative;\n            padding: 10px;\n\n            input, select {\n                position: absolute;\n                top: 0;\n                bottom: 0;\n                right: 0;\n                margin: 0;\n                border: 0;\n            }\n\n            input[type=checkbox] {\n                padding: 10px;\n            }\n\n            select {\n                width: 50%;\n            }\n        }\n\n        button {\n            display: block;\n            width: 100%;\n            margin: 0;\n            border: 0;\n            padding: 10px;\n        }\n    }\n}\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<!--\n    Copyright 2021 James Deery\n    Released under the MIT licence, https://opensource.org/licenses/MIT\n -->\n<html lang=\"en\">\n<head>\n    <title>M8 Display</title>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"theme-color\" content=\"#000\" />\n    <link rel=\"shortcut icon\" type=\"image/png\" href=\"favicon.png\">\n    <link rel=\"apple-touch-icon\" href=\"icon.png\">\n    <link rel=\"manifest\" href=\"app.webmanifest\">\n    <link rel=\"stylesheet\" href=\"build/index.css\">\n</head>\n<body>\n    <div id=\"display\">\n        <canvas id=\"canvas\" width=\"320\" height=\"240\"></canvas>\n\n        <div id=\"buttons\" class=\"hidden\">\n            <button id=\"connect\">Connect</button>\n        </div>\n\n        <div id=\"mapping-buttons\"></div>\n\n        <div id=\"mapping-help\">\n            <p class=\"select-action\">Click a key to add a new mapping</p>\n            <p class=\"enter-input\">Press a keyboard key or gamepad button</p>\n        </div>\n\n        <div id=\"controls\">\n            <div data-action=\"edit\" style=\"top: 2.0833%; left: 76.5625%\"></div>\n            <div data-action=\"option\" style=\"top: 2.0833%; left: 53.125%\"></div>\n            <div data-action=\"up\" style=\"top: 6.25%; left: 25%\"></div>\n            <div data-action=\"left\" style=\"top: 37.5%; left: 1.5625%\"></div>\n            <div data-action=\"down\" style=\"top: 37.5%; left: 25%\"></div>\n            <div data-action=\"right\" style=\"top: 37.5%; left: 48.4375%\"></div>\n            <div data-action=\"select\" style=\"top: 70.8333%; left: 27.3438%\"></div>\n            <div data-action=\"start\" style=\"top: 70.8333%; left: 50.7813%\"></div>\n        </div>\n    </div>\n\n    <div id=\"serial-fail\" class=\"error hidden\">\n        <p>Failed to connect over Serial.\n        Are you sure your Teensy is connected and the M8 Headless firmware is loaded?</p>\n        <p>Make sure that there are no other programs running which may have the serial port open.</p>\n        <p>On Linux you may also need make sure you have permission to open the serial port (eg. make yourself a member of the <code>dialout</code> group).</p>\n        <p>It is also possible there's a bug in this code.\n        There may be some messages in the developer console that will help with debugging.</a>\n    </div>\n    <div id=\"usb-fail\" class=\"error hidden\">\n        <p>Failed to connect with WebUSB.\n        Are you sure your Teensy is connected and the M8 Headless firmware is loaded?</p>\n        <p>Connecting with WebUSB is known not to work on Windows, Linux and some Samsung phones.\n        Please make sure you are using an up to date version of Chrome or Edge (89+).</p>\n    </div>\n    <div id=\"no-serial-usb\" class=\"error hidden\">\n        <p>Your browser doesn't appear to have Serial or WebUSB support.\n        These are only currently supported in Chrome and some Chrome-derived browsers.</p>\n    </div>\n\n    <div id=\"info\">\n        <h1>M8 Display</h1>\n        <p>This is a display for the <a href=\"https://github.com/DirtyWave/M8HeadlessFirmware\" target=\"_blank\" rel=\"noopener\">M8 Headless firmware</a> running on a Teensy 4.1, or a <a href=\"https://dirtywave.com/\" target=\"_blank\" rel=\"noopener\">real M8 device</a>.\n        You can use keyboard and gamepad input or use on-screen controls.\n        Where possible, the audio output from the M8 is routed to your default audio output device.\n        Firmware can be installed and updated from the menu.</p>\n        <p>By default the arrow keys on your keyboard map to the arrow keys on the M8.\n        Shift is <kbd>Left Shift</kbd>, Play is <kbd>Space</kbd>, Option is <kbd>Z</kbd>, and Edit is <kbd>X</kbd>.\n        These control mappings and other settings can be configured from the menu.</p>\n        <p>A virtual keyboard from <kbd>A</kbd> to <kbd>'</kbd> lets you send MIDI notes.\n        Use <kbd>-</kbd>/<kbd>=</kbd> to change octaves and <kbd>[</kbd>/<kbd>]</kbd> to change velocity.</p>\n        <p>Now that this page has loaded it will work completely offline.\n        All settings are stored locally by your browser.</p>\n        <p>Source code and more details are available at <a href=\"https://github.com/derkyjadex/M8WebDisplay\" target=\"_blank\" rel=\"noopener\">github.com/<wbr>derkyjadex/<wbr>M8WebDisplay</a>.</p>\n        <button>OK</button>\n        <p class=\"build\">Build BUILDNUM</p>\n    </div>\n\n    <div id=\"settings\" class=\"hidden\">\n        <button id=\"menu-button\">Menu</button>\n    </div>\n\n    <div id=\"reload\" class=\"hidden\">\n        <button>An update is available. Click to reload.</button>\n    </div>\n\n    <div id=\"capture-shield\"></div>\n\n    <div id=\"firmware\" class=\"hidden\">\n        <p class=\"select-file-msg\">Select a firmware file</p>\n        <input type=\"file\" accept=\".hex\">\n        <p class=\"file-loading-msg\">Loading file...</p>\n        <p class=\"file-error-msg\">The selected file does not appear to be a valid Teensy 4.1 firmware file</p>\n        <p class=\"select-device-msg\">Connect your Teensy board and press the little button on the board</p>\n        <button class=\"select-device\">Select Teensy Device</button>\n        <p class=\"device-error-msg\">The selected device does not appear to be a Teensy 4.1</p>\n        <p class=\"flash-msg\">Ready to flash</p>\n        <button class=\"flash\">Flash</button>\n        <p class=\"flashing-msg\">Flashing...</p>\n        <progress class=\"flash\"></progress>\n        <p class=\"flash-error-msg\">There was an error flashing the device. You can try again.</p>\n        <p class=\"flash-success-msg\">Flashing completed successfully</p>\n        <button class=\"close\">Close</button>\n    </div>\n\n    <script type=\"module\" src=\"js/main.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "js/audio.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait, on, off } from './util.js';\n\nlet ctx;\nlet enabled = true;\n\nexport async function start(attempts = 1) {\n    if (ctx || !enabled)\n        return;\n\n    try {\n        ctx = new AudioContext();\n\n        await navigator.mediaDevices.getUserMedia({ audio: true });\n        let deviceId;\n        while (true) {\n            deviceId = await findDeviceId();\n            if (deviceId)\n                break;\n\n            if (--attempts > 0) {\n                await wait(300);\n            } else {\n                break;\n            }\n        }\n\n        if (!deviceId)\n            throw new Error('M8 not found');\n\n        const stream = await navigator.mediaDevices\n            .getUserMedia({ audio: {\n                deviceId: { exact: deviceId },\n                autoGainControl: false,\n                echoCancellation: false,\n                noiseSuppression: false\n            } })\n\n        const source = ctx.createMediaStreamSource(stream);\n        source.connect(ctx.destination);\n\n        if (ctx.state !== 'running') {\n            waitForUserGesture();\n        }\n\n    } catch (err) {\n        console.error(err);\n        stop();\n    }\n\n    if (!enabled) {\n        stop();\n    }\n}\n\nasync function findDeviceId() {\n    const devices = await navigator.mediaDevices.enumerateDevices();\n    return devices\n        .filter(d =>\n            d.kind === 'audioinput' &&\n            /M8/.test(d.label) &&\n            d.deviceId !== 'default' &&\n            d.deviceId !== 'communications')\n        .map(d => d.deviceId)[0];\n}\n\nexport async function stop() {\n    ctx && await ctx.close().catch(() => {});\n    ctx = null;\n}\n\nfunction waitForUserGesture() {\n    const events = ['keydown', 'mousedown', 'touchstart'];\n\n    function resume() {\n        ctx && ctx.resume();\n        events.forEach(e =>\n            off(document, e, resume));\n    }\n\n    events.forEach(e =>\n        on(document, e, resume));\n}\n\nexport function enable() {\n    if (enabled)\n        return;\n\n    enabled = true;\n    start();\n}\n\nexport function disable() {\n    enabled = false;\n    stop();\n}\n"
  },
  {
    "path": "js/firmware.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show, hide, toggle, on, wait } from './util.js';\nimport { readHexToBlocks } from './hex.js';\n\nfunction setState(state) {\n    document.getElementById('firmware').className = state;\n}\n\nexport function open() {\n    blocks = null;\n    device = null;\n    setState('file-select');\n}\n\nasync function flash(blocks, device, onProgress) {\n    onProgress(0);\n\n    await device.open();\n    try {\n        const buffer = new Uint8Array(64 + 1024);\n\n        for (let i = 0; i < blocks.length; i++) {\n            if (i !== 0 && (!blocks[i] || blocks[i].every(b => b === 0xff)))\n                continue;\n\n            const addr = i * 1024;\n            buffer[0] = addr & 0xff;\n            buffer[1] = (addr >> 8) & 0xff;\n            buffer[2] = (addr >> 16) & 0xff;\n            buffer.set(blocks[i], 64);\n\n            await device.sendReport(0, buffer);\n            onProgress((i + 1) / blocks.length);\n            await wait(i === 0 ? 1500 : 5);\n        }\n\n        buffer.fill(0);\n        buffer[0] = 0xff;\n        buffer[1] = 0xff;\n        buffer[2] = 0xff;\n        await device.sendReport(0, buffer);\n\n    } finally {\n        await device.close().catch(() => {});\n    }\n}\n\nfunction isTeensy(device) {\n    const info = device.collections[0];\n    return info\n        && info.usagePage === 0xff9c\n        && info.usage === 0x25;\n}\n\nlet blocks = null;\nlet device = null;\n\non('#firmware button.close', 'click', () => {\n    blocks = null;\n    device = null;\n    document\n        .querySelector('#firmware input')\n        .value = null;\n\n    setState('hidden');\n});\n\non('#firmware input', 'change', async e => {\n    blocks = null;\n\n    const file = e.target.files[0];\n    if (!file)\n        return;\n\n    setState('file-loading');\n    try {\n        blocks = await readHexToBlocks(file, 1024, 0x60000000);\n    } catch (error) {\n        console.error(error);\n        setState('file-error');\n        return;\n    }\n\n    setState('file-loaded');\n});\n\non('#firmware button.select-device', 'click', async () => {\n    device = null;\n    const result = await navigator.hid.requestDevice({\n        filters: [{\n            vendorId: 0x16c0,\n            productId: 0x0478\n        }]\n    });\n    device = result && result[0];\n\n    if (!device)\n        return;\n\n    if (isTeensy(device)) {\n        setState('device-selected');\n    } else {\n        device = null;\n        setState('device-error');\n    }\n});\n\non('#firmware button.flash', 'click', async () => {\n    const progress = document.querySelector('#firmware progress.flash');\n\n    setState('flashing');\n    try {\n        await flash(blocks, device, p => progress.value = p);\n    } catch (error) {\n        console.error(error);\n        setState('flash-error');\n        return;\n    }\n\n    setState('flash-success');\n});\n"
  },
  {
    "path": "js/gl-renderer.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport * as Shaders from '../build/shaders.js';\nimport { font1 } from '../build/font1.js';\nimport { font2 } from '../build/font2.js';\n\nconst MAX_RECTS = 1024;\n\nexport class Renderer {\n    _canvas;\n    _gl;\n    _bg = [0, 0, 0];\n    _frameQueued = false;\n    _onBackgroundChanged;\n\n    _fontConfig = [\n        //glyph x, y, hoffset, voffset\n        [8,10,0,0],\n        [10,12,0,-40]\n    ];\n    _fontId = 0;\n\n    constructor(bg, onBackgroundChanged) {\n        this._bg = [bg[0] / 255, bg[1] / 255, bg[2] / 255];\n        this._onBackgroundChanged = onBackgroundChanged;\n\n        this._canvas = document.getElementById('canvas')\n        this._gl = this._canvas.getContext('webgl2', {\n            alpha: false,\n            antialias: false\n        });\n\n        const gl = this._gl;\n\n        this._setupRects(gl);\n        this._setupText(gl);\n        this._setupWave(gl);\n\n        gl.enable(gl.BLEND);\n        gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n        gl.viewport(0,0, 320, 240);\n\n        this._queueFrame();\n    }\n\n    setFont(f) {\n        if(this._fontId == f) return;\n        this._fontId = f;\n        const gl = this._gl;\n        this._setupText(gl);\n    }\n\n    _rectShader;\n    _rectVao;\n    _rectShapes = new Uint16Array(MAX_RECTS * 6);\n    _rectColours = new Uint8Array(this._rectShapes.buffer, 8);\n    _rectCount = 0;\n    _rectsClear = true;\n    _rectsTex;\n    _rectsFramebuffer;\n    _blitShader;\n\n    _setupRects(gl) {\n        this._rectShader = buildProgram(gl, 'rect');\n\n        this._rectVao = gl.createVertexArray();\n        gl.bindVertexArray(this._rectVao);\n\n        this._rectShapes.glBuffer = gl.createBuffer();\n        gl.bindBuffer(gl.ARRAY_BUFFER, this._rectShapes.glBuffer);\n        gl.bufferData(gl.ARRAY_BUFFER, this._rectShapes, gl.STREAM_DRAW);\n        gl.enableVertexAttribArray(0);\n        gl.vertexAttribPointer(0, 4, gl.UNSIGNED_SHORT, false, 12, 0);\n        gl.vertexAttribDivisor(0, 1);\n        gl.enableVertexAttribArray(1);\n        gl.vertexAttribPointer(1, 3, gl.UNSIGNED_BYTE, true, 12, 8);\n        gl.vertexAttribDivisor(1, 1);\n\n        this._rectsTex = gl.createTexture();\n        gl.activeTexture(gl.TEXTURE0);\n        gl.bindTexture(gl.TEXTURE_2D, this._rectsTex);\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 320, 240, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n\n        this._rectsFramebuffer = gl.createFramebuffer();\n        gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer);\n        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._rectsTex, 0);\n\n        this._blitShader = buildProgram(gl, 'blit');\n        gl.useProgram(this._blitShader);\n        gl.uniform1i(gl.getUniformLocation(this._blitShader, 'src'), 0);\n    }\n\n    _renderRects(gl) {\n        if (this._rectsClear) {\n            gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer);\n\n            gl.clearColor(this._bg[0], this._bg[1], this._bg[2], 1);\n            gl.clear(gl.COLOR_BUFFER_BIT);\n            this._rectsClear = false;\n        }\n\n        if (this._rectCount > 0) {\n            gl.bindFramebuffer(gl.FRAMEBUFFER, this._rectsFramebuffer);\n\n            gl.useProgram(this._rectShader);\n            gl.bindVertexArray(this._rectVao);\n\n            gl.bindBuffer(gl.ARRAY_BUFFER, this._rectShapes.glBuffer);\n            gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._rectShapes.subarray(0, this._rectCount * 6));\n\n            gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, this._rectCount);\n\n            this._rectCount = 0;\n        }\n\n        gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n        gl.useProgram(this._blitShader);\n        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n    }\n\n    drawRect(x, y, w, h, r, g, b) {\n        if (x === 0 && y === 0 && w >= 320 && h >= 240) {\n            this._onBackgroundChanged(r, g, b);\n\n            this._bg = [r / 255, g / 255, b / 255];\n            this._rectCount = 0;\n            this._rectsClear = true;\n\n        } else if (this._rectCount < MAX_RECTS) {\n            const i = this._rectCount;\n            this._rectShapes[i * 6 + 0] = x;\n            this._rectShapes[i * 6 + 1] = y ? y+this._fontConfig[this._fontId][3] : y;\n            this._rectShapes[i * 6 + 2] = w;\n            this._rectShapes[i * 6 + 3] = h;\n            this._rectColours[i * 12 + 0] = r;\n            this._rectColours[i * 12 + 1] = g;\n            this._rectColours[i * 12 + 2] = b;\n            this._rectCount++;\n        }\n\n        if (this._rectCount >= MAX_RECTS) {\n            this._renderRects(this._gl);\n        }\n\n        this._queueFrame();\n    }\n\n    _textShader;\n    _textVao;\n    _textTex;\n    _textColours = new Uint8Array(40 * 24 * 3);\n    _textChars = new Uint8Array(40 * 24);\n\n    _setupText(gl) {\n        \n        if(this._fontId == 0) {\n            this._textShader = buildProgram(gl, 'text1');\n        } else {\n            this._textShader = buildProgram(gl, 'text2');\n        }\n        gl.useProgram(this._textShader);\n        gl.uniform1i(gl.getUniformLocation(this._textShader, 'font'), 1);\n\n        this._textVao = gl.createVertexArray();\n        gl.bindVertexArray(this._textVao);\n\n        this._textColours.glBuffer = gl.createBuffer();\n        gl.bindBuffer(gl.ARRAY_BUFFER, this._textColours.glBuffer);\n        gl.bufferData(gl.ARRAY_BUFFER, this._textColours, gl.DYNAMIC_DRAW);\n        gl.enableVertexAttribArray(0);\n        gl.vertexAttribPointer(0, 3, gl.UNSIGNED_BYTE, true, 0, 0);\n        gl.vertexAttribDivisor(0, 1);\n\n        this._textChars.glBuffer = gl.createBuffer();\n        gl.bindBuffer(gl.ARRAY_BUFFER, this._textChars.glBuffer);\n        gl.bufferData(gl.ARRAY_BUFFER, this._textChars, gl.DYNAMIC_DRAW);\n        gl.enableVertexAttribArray(1);\n        gl.vertexAttribPointer(1, 1, gl.UNSIGNED_BYTE, false, 0, 0);\n        gl.vertexAttribDivisor(1, 1);\n\n        this._textTex = gl.createTexture();\n        gl.activeTexture(gl.TEXTURE1);\n        gl.bindTexture(gl.TEXTURE_2D, this._textTex);\n\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n\n        if(this._fontId == 0) {\n            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 470, 7, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n            const fontImage1 = new Image();\n            fontImage1.onload = () => {\n                gl.activeTexture(gl.TEXTURE1);\n                gl.bindTexture(gl.TEXTURE_2D, this._textTex);\n                gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 470, 7, 0, gl.RGBA, gl.UNSIGNED_BYTE, fontImage1);\n                this._queueFrame();\n            }\n            fontImage1.src = font1;\n        } else {\n            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 752, 9, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);\n            const fontImage2 = new Image();\n            fontImage2.onload = () => {\n                gl.activeTexture(gl.TEXTURE1);\n                gl.bindTexture(gl.TEXTURE_2D, this._textTex);\n                gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 752, 9, 0, gl.RGBA, gl.UNSIGNED_BYTE, fontImage2);\n                this._queueFrame();\n            }\n            fontImage2.src = font2;\n        }\n        \n    }\n\n    _renderText(gl) {\n        gl.useProgram(this._textShader);\n        gl.bindVertexArray(this._textVao);\n\n        if (this._textColours.updated) {\n            gl.bindBuffer(gl.ARRAY_BUFFER, this._textColours.glBuffer);\n            gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._textColours);\n            this._textColours.updated = false;\n        }\n\n        if (this._textChars.updated) {\n            gl.bindBuffer(gl.ARRAY_BUFFER, this._textChars.glBuffer);\n            gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._textChars);\n            this._textChars.updated = false;\n        }\n\n        gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 40 * 24);\n    }\n\n    drawText(c, x, y, r, g, b) {\n        const i = Math.floor(y / this._fontConfig[this._fontId][1]) * 40 + Math.floor(x / this._fontConfig[this._fontId][0]);\n        if(i >= 960) return;\n        this._textChars[i] = c - 32;\n        this._textChars.updated = true;\n        this._textColours[i * 3 + 0] = r;\n        this._textColours[i * 3 + 1] = g;\n        this._textColours[i * 3 + 2] = b;\n        this._textColours.updated = true;\n\n        this._queueFrame();\n    }\n    \n    _waveData = new Uint8Array(320);\n    _waveColour = new Float32Array([0.5, 1, 1]);\n    _waveOn = false;\n\n    _setupWave(gl) {\n        this._waveShader = buildProgram(gl, 'wave');\n        this._waveShader.colourUniform = gl.getUniformLocation(this._waveShader, 'colour');\n        this._waveVao = gl.createVertexArray();\n        gl.bindVertexArray(this._waveVao);\n\n        this._waveData.glBuffer = gl.createBuffer();\n        gl.bindBuffer(gl.ARRAY_BUFFER, this._waveData.glBuffer);\n        gl.bufferData(gl.ARRAY_BUFFER, this._waveData, gl.STREAM_DRAW);\n        gl.enableVertexAttribArray(0);\n        gl.vertexAttribIPointer(0, 1, gl.UNSIGNED_BYTE, 1, 0);\n    }\n\n    _renderWave(gl) {\n        if (this._waveOn) {\n            gl.useProgram(this._waveShader);\n            gl.uniform3fv(this._waveShader.colourUniform, this._waveColour);\n            gl.bindVertexArray(this._waveVao);\n\n            if (this._waveData.updated) {\n                gl.bindBuffer(gl.ARRAY_BUFFER, this._waveData.glBuffer);\n                gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._waveData);\n                this._waveData.updated = false;\n            }\n\n            gl.drawArrays(gl.POINTS, 0, 320);\n        }\n    }\n\n    drawWave(r, g, b, data) {\n        this._waveColour[0] = r / 255;\n        this._waveColour[1] = g / 255;\n        this._waveColour[2] = b / 255;\n        \n        if (data.length != 0) {\n            this._waveData.fill(-1);\n            this._waveData.set(data, 320-data.length);\n            this._waveData.updated = true;\n            this._waveOn = true;\n            this._queueFrame();\n\n        } else if (this._waveOn) {\n            this._waveOn = false;\n            this._queueFrame();\n        }\n    }\n\n    _renderFrame() {\n        const gl = this._gl;\n\n        this._renderRects(gl);\n        this._renderText(gl);\n        this._renderWave(gl);\n\n        this._frameQueued = false;\n    }\n\n    _queueFrame() {\n        if (!this._frameQueued) {\n            requestAnimationFrame(() => this._renderFrame());\n            this._frameQueued = true;\n        }\n    }\n\n    clear() {\n        this._rectsClear = true;\n        this._rectCount = 0;\n        this._textChars.fill(0);\n        this._textChars.updated = true;\n        this._waveOn = false;\n\n        this._queueFrame();\n    }\n}\n\nfunction compileShader(gl, name, type) {\n    const shader = gl.createShader(type);\n    gl.shaderSource(shader, Shaders[name]);\n    gl.compileShader(shader);\n\n    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))\n        throw new Error(`Failed to compile shader (${name}): ${gl.getShaderInfoLog(shader)}`);\n\n    return shader;\n}\n\nfunction linkProgram(gl, name, vertexShader, fragmentShader) {\n    const program = gl.createProgram();\n\n    gl.attachShader(program, vertexShader);\n    gl.attachShader(program, fragmentShader);\n    gl.linkProgram(program);\n\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS))\n        throw new Error(`Failed to link program (${name}): ${gl.getProgramInfoLog(program)}`);\n\n    return program;\n}\n\nfunction buildProgram(gl, name) {\n    return linkProgram(\n        gl,\n        name,\n        compileShader(gl, `${name}_vert`, gl.VERTEX_SHADER),\n        compileShader(gl, `${name}_frag`, gl.FRAGMENT_SHADER));\n}\n"
  },
  {
    "path": "js/hex.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst START_LINE = Symbol('START_LINE');\nconst HEX1 = Symbol('HEX1');\nconst HEX2 = Symbol('HEX2');\n\nexport async function readHexToBlocks(file, blockSize, offset) {\n\tconst blocks = [];\n\n\tfor await (let { address, data } of parseHex(file)) {\n        address -= offset;\n        if (address < 0)\n            throw new HexFormatError(`Negative address after applying offset`);\n\n\t\tlet blockIndex = Math.floor(address / blockSize);\n\t\tlet dataIndex = 0;\n\t\twhile (dataIndex < data.length) {\n\t\t\tlet block = blocks[blockIndex];\n\t\t\tif (!block) {\n\t\t\t\tblock = blocks[blockIndex] = new Uint8Array(blockSize);\n                block.fill(0xff);\n\t\t\t}\n\n\t\t\tconst blockStart = blockIndex * blockSize;\n\t\t\tconst blockEnd = blockStart + blockSize;\n\t\t\tconst copyStart = address + dataIndex - blockStart;\n\t\t\tconst copyEnd = address + data.length > blockEnd\n\t\t\t\t? blockSize\n\t\t\t\t: copyStart + data.length - dataIndex;\n\n\t\t\tfor (let i = copyStart; i < copyEnd; i++) {\n\t\t\t\tblock[i] = data[dataIndex++];\n\t\t\t}\n\t\t\tblockIndex++;\n\t\t}\n\t}\n\n\treturn blocks;\n}\n\nasync function* parseHex(file) {\n\tconst buffer = new Uint8Array(260);\n\tlet state = START_LINE;\n\tlet line = 1;\n\tlet char = 0;\n\tlet bufferIndex = 0;\n\tlet value = 0;\n\tlet checksum = 0;\n\tlet lineBytes = 1;\n\tlet seenEnd = false;\n\tlet baseAddress = 0;\n\n    const reader = file.stream().getReader();\n    while (true) {\n        const result = await reader.read();\n\n        if (!result.value)\n            break;\n\n        for (const byte of result.value) {\n            char++;\n\n            if (seenEnd)\n                throw new HexFormatError(\n                    `Unexpected data after end record, line ${line} char ${char}`);\n\n            switch (state) {\n                case START_LINE:\n                    switch (byte) {\n                        case 0x3a: // :\n                            state = HEX1;\n                            break;\n\n                        default:\n                            throw new HexFormatError(\n                                `Expecting ':' at start of line ${line} char ${char}`);\n                    }\n                    break;\n\n                case HEX1:\n                    if (byte === 0x0d) // \\r\n                        continue;\n\n                    if (byte === 0x0a) { // \\n\n                        if (bufferIndex < lineBytes)\n                            throw new HexFormatError(\n                                `Unexpected end of line on line ${line} char ${char}`);\n\n                        if (checksum !== 0)\n                            throw new HexFormatError(\n                                `Invalid checksum on line ${line}`);\n\n                        switch (buffer[3]) {\n                            case 0x00:\n                                yield {\n                                    address: baseAddress + buffer[1] * 256 + buffer[2],\n                                    data: buffer.subarray(4, lineBytes - 1)\n                                };\n                                break;\n\n                            case 0x01:\n                                seenEnd = true;\n                                break;\n\n                            case 0x02:\n                                baseAddress = (buffer[4] * 256 + buffer[5]) << 4;\n                                break;\n\n                            case 0x03:\n                                break;\n\n                            case 0x04:\n                                baseAddress = (buffer[4] * 256 + buffer[5]) << 16;\n                                break;\n\n                            case 0x05:\n                                break;\n\n                            default:\n                                throw new HexFormatError(\n                                    `Invalid record type on line ${line}`);\n                        }\n\n                        state = START_LINE;\n                        line++;\n                        char = 0;\n                        bufferIndex = 0;\n                        checksum = 0;\n                        lineBytes = 1;\n\n                    } else {\n                        if (bufferIndex >= lineBytes)\n                            throw new HexFormatError(\n                                `Record too long on line ${line} char ${char}`);\n\n                        const hexValue = fromHex(byte);\n                        if (hexValue === null)\n                            throw new HexFormatError(\n                                `Expecting hex character on line ${line} char ${char}`);\n\n                        value = hexValue * 16;\n                        state = HEX2;\n                    }\n                    break;\n\n                case HEX2:\n                    const hexValue = fromHex(byte);\n                    if (hexValue === null)\n                        throw new HexFormatError(\n                            `Expecting hex character on line ${line} char ${char}`);\n\n                    value += hexValue;\n                    checksum = (checksum + value) & 0xFF;\n                    if (bufferIndex === 0) {\n                        lineBytes = value + 5;\n                    }\n                    buffer[bufferIndex++] = value;\n                    state = HEX1;\n                    break;\n            }\n        }\n\n        if (result.done)\n            break;\n    }\n\n    if (seenEnd)\n        return;\n\n    if (state != HEX1 || byte < lineBytes)\n        throw new HexFormatError(\n            `Unexpected end of file, line ${line} char ${char}`);\n\n    if (checksum !== 0)\n        throw new HexFormatError(\n            `Invalid checksum on line ${line}`);\n\n    const type = buffer[3];\n    if (type !== 0x01)\n        throw new HexFormatError(\n            `Missing end of file record, line ${line}`);\n}\n\nfunction fromHex(byte) {\n    if (byte >= 0x30 && byte <= 0x39)\n        return byte - 0x30;\n\n    if (byte >= 0x41 && byte <= 0x46)\n        return byte - 0x37;\n\n    if (byte >= 0x61 && byte <= 0x66)\n        return byte - 0x57;\n\n    return null;\n}\n\nexport class HexFormatError extends Error {\n    constructor(...params) {\n        super(...params);\n        this.name = 'HexFormatError';\n    }\n}\n"
  },
  {
    "path": "js/input.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { appendButton, on, off } from './util.js';\nimport * as Settings from './settings.js';\nimport * as Keyboard from './keyboard.js';\n\nlet connection;\nlet keyState = 0;\n\nconst keyBitMap = {\n    up: 6,\n    down: 5,\n    left: 7,\n    right: 2,\n    select: 4,\n    start: 3,\n    option: 1,\n    edit: 0\n};\n\nconst defaultInputMap = Object.freeze({\n    ArrowUp: 'up',\n    ArrowDown: 'down',\n    ArrowLeft: 'left',\n    ArrowRight: 'right',\n    ShiftLeft: 'select',\n    Space: 'start',\n    KeyZ: 'option',\n    KeyX: 'edit',\n\n    Gamepad12: 'up',\n    Gamepad64: 'up',\n    Gamepad13: 'down',\n    Gamepad65: 'down',\n    Gamepad14: 'left',\n    Gamepad66: 'left',\n    Gamepad15: 'right',\n    Gamepad67: 'right',\n    Gamepad8: 'select',\n    Gamepad2: 'select',\n    Gamepad5: 'select',\n    Gamepad9: 'start',\n    Gamepad3: 'start',\n    Gamepad1: 'option',\n    Gamepad0: 'edit'\n});\n\nconst inputMap = {};\n\nfunction handleInput(input, isDown, e) {\n    if (!input)\n        return;\n\n    if (resolveCapture) {\n        e && e.preventDefault();\n        if (isDown) {\n            resolveCapture(input);\n        }\n        return;\n    }\n\n    if (Keyboard.handleKey(input, isDown, e))\n        return;\n\n    handleAction(inputMap[input], isDown, e);\n}\n\nfunction handleControl(isDown, e) {\n    const action = e.target.dataset.action;\n    if (!action)\n        return;\n\n    if (isMapping && isDown && !resolveCapture) {\n        startMapKey(e.target, action);\n\n    } else {\n        handleAction(action, isDown, e);\n    }\n}\n\nfunction handleAction(action, isDown, e) {\n    if (!action)\n        return;\n\n    e && e.preventDefault();\n\n    const bit = keyBitMap[action];\n    if (bit === undefined)\n        return;\n\n    const newState = isDown\n        ? keyState | (1 << bit)\n        : keyState & ~(1 << bit);\n\n    if (newState === keyState)\n        return;\n\n    keyState = newState;\n\n    connection.sendKeys(keyState);\n\n    document\n        .querySelector(`#controls > [data-action=\"${action}\"]`)\n        .classList\n        .toggle('active', isDown);\n}\n\nexport function setup(connection_) {\n    connection = connection_;\n\n    Keyboard.setup(connection);\n\n    on(document, 'keydown', e =>\n        handleInput(e.code, true, e));\n\n    on(document, 'keyup', e =>\n        handleInput(e.code, false, e));\n\n    const controls = document.getElementById('controls');\n\n    on(controls, 'mousedown', e =>\n        handleControl(true, e));\n\n    on(controls, 'touchstart', e =>\n        handleControl(true, e));\n\n    on(controls, 'mouseup', e =>\n        handleControl(false, e));\n\n    on(controls, 'touchend', e =>\n        handleControl(false, e));\n\n    appendButton('#mapping-buttons', 'Reset to Default', resetMappings);\n    appendButton('#mapping-buttons', 'Clear All', clearMappings);\n    appendButton('#mapping-buttons', 'Done', stopMapping);\n\n    Object.assign(\n        inputMap,\n        Settings.load('inputMap', defaultInputMap));\n}\n\nlet gamepadsRunning = false;\nconst gamepadStates = [];\nconst hatMap = {\n    0: [true, false, false, false],\n    1: [true, false, false, true],\n    2: [false, false, false, true],\n    3: [false, true, false, true],\n    4: [false, true, false, false],\n    5: [false, true, true, false],\n    6: [false, false, true, false],\n    7: [true, false, true, false],\n    8: [false, false, false, false],\n    15: [false, false, false, false],\n};\n\nfunction pollGamepads() {\n    if (!gamepadsRunning)\n        return;\n\n    let somethingPresent = false;\n    for (const gamepad of navigator.getGamepads()) {\n        if (!gamepad || !gamepad.connected)\n            continue;\n\n        somethingPresent = true;\n\n        let state = gamepadStates[gamepad.index];\n        if (!state) {\n            state = gamepadStates[gamepad.index] = {\n                buttons: [],\n                axes: Array(gamepad.axes.length).fill(null).map(_ => ({}))\n            };\n        }\n\n        if (gamepad.mapping !== 'standard') {\n            for (let i = 0; i < gamepad.axes.length; i++) {\n                if (state.axes[i].isHat === false)\n                    continue;\n\n                // Heuristics to locate a d-pad or\n                // \"hat switch\" masquerading as an axis\n                const value = (gamepad.axes[i] + 1) * 3.5;\n                const error = Math.abs(Math.round(value) - value);\n                const hatPosition = hatMap[Math.round(value)];\n                if (error > 4.8e-7 || hatPosition === undefined) {\n                    // definitely not a hat based on this value\n                    state.axes[i].isHat = false;\n                    continue;\n                } else if (value === 0 && state.axes[i].isHat !== true) {\n                    // could be a hat but could also be an unpressed trigger\n                    continue;\n                } else {\n                    // almost certainly a hat - we're very close to a \"special\"\n                    // value and we haven't seen any invalid values\n                    state.axes[i].isHat = true;\n                }\n\n                for (let b = 0; b < 4; b++) {\n                    const pressed = hatPosition[b];\n                    if (state.buttons[64 + b] !== pressed) {\n                        state.buttons[64 + b] = pressed;\n                        handleInput(`Gamepad${64 + b}`, pressed);\n                    }\n                }\n            }\n        }\n\n        for (let i = 0; i < gamepad.axes.length; i++) {\n            const value = gamepad.axes[i];\n            if (state.axes[i].isHat === true || Math.abs(value) > 1)\n                continue;\n\n            const negative = value <= -0.5;\n            const positive = value >= 0.5;\n            if (state.axes[i].negative !== negative) {\n                state.axes[i].negative = negative;\n                handleInput(`GamepadAxis${i}-`, negative);\n            }\n            if (state.axes[i].positive !== positive) {\n                state.axes[i].positive = positive;\n                handleInput(`GamepadAxis${i}+`, positive);\n            }\n        }\n\n        for (let i = 0; i < gamepad.buttons.length; i++) {\n            const pressed = gamepad.buttons[i].pressed;\n            if (state.buttons[i] !== pressed) {\n                state.buttons[i] = pressed;\n                handleInput(`Gamepad${i}`, pressed);\n            }\n        }\n    }\n\n    if (somethingPresent) {\n        requestAnimationFrame(pollGamepads);\n    } else {\n        gamepadsRunning = false;\n    }\n}\n\non(window, 'gamepadconnected', e => {\n    if (e.gamepad.mapping !== 'standard') {\n        console.warn('Non-standard gamepad attached. Mappings may be funny.');\n    }\n\n    if (!gamepadsRunning) {\n        gamepadsRunning = true;\n        pollGamepads();\n    }\n});\n\non(window, 'gamepaddisconnected', e => {\n    gamepadStates[e.gamepad.index] = null;\n});\n\nexport let isMapping = false;\nlet resolveMapping = null;\nlet resolveCapture = null;\n\nexport function startMapping() {\n    isMapping = true;\n    document.body.classList.add('mapping');\n    return new Promise(resolve => { resolveMapping = resolve });\n}\n\nexport function stopMapping() {\n    cancelCapture();\n    document.body.classList.remove('mapping');\n    isMapping = false;\n    resolveMapping && resolveMapping();\n}\n\nexport function captureNextInput() {\n    cancelCapture();\n    return new Promise(\n        resolve => { resolveCapture = resolve; })\n        .then(input => {\n            resolveCapture = null;\n            return input;\n        });\n}\n\nexport function cancelCapture() {\n    resolveCapture && resolveCapture(null);\n}\n\nasync function startMapKey(keyElement, action) {\n    const cancel = e => {\n        e.stopPropagation();\n        cancelCapture();\n    };\n\n    on(document.body, 'mousedown', cancel, true);\n    on(document.body, 'touchstart', cancel, true);\n    document.body.classList.add('capturing');\n    keyElement.classList.add('mapping');\n    try {\n        const input = await captureNextInput();\n        if (input) {\n            inputMap[input] = action;\n            Settings.save('inputMap', inputMap);\n        }\n    } finally {\n        keyElement.classList.remove('mapping');\n        document.body.classList.remove('capturing');\n        off(document.body, 'touchstart', cancel, true);\n        off(document.body, 'mousedown', cancel, true);\n    }\n}\n\nexport function resetMappings() {\n    for (const input of Object.keys(inputMap)) {\n        delete inputMap[input];\n    }\n    Object.assign(inputMap, defaultInputMap);\n    Settings.save('inputMap', inputMap);\n}\n\nexport function clearMappings() {\n    for (const input of Object.keys(inputMap)) {\n        delete inputMap[input];\n    }\n    Settings.save('inputMap', inputMap);\n}\n"
  },
  {
    "path": "js/keyboard.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport * as Settings from './settings.js';\n\nconst keyMap = Object.freeze({\n    KeyA: 0,\n    KeyW: 1,\n    KeyS: 2,\n    KeyE: 3,\n    KeyD: 4,\n    KeyF: 5,\n    KeyT: 6,\n    KeyG: 7,\n    KeyY: 8,\n    KeyH: 9,\n    KeyU: 10,\n    KeyJ: 11,\n    KeyK: 12,\n    KeyO: 13,\n    KeyL: 14,\n    KeyP: 15,\n    Semicolon: 16,\n    Quote: 17,\n    BracketLeft: 'velDown',\n    BracketRight: 'velUp',\n    Minus: 'octDown',\n    Equal: 'octUp'\n});\n\nlet connection;\nlet enabled = true;\nlet oct = 3;\nlet vel = 103;\nlet currentKey = null;\n\nexport function handleKey(input, isDown, e) {\n    if (!enabled || !e || e.ctrlKey || e.metaKey || e.altKey)\n        return false;\n\n    const key = keyMap[input];\n    if (key === undefined)\n        return false;\n\n    if (e.repeat)\n        return true;\n\n    switch (key) {\n        case 'octDown':\n            if (isDown) {\n                oct = Math.max(oct - 1, 0);\n            }\n            break;\n\n        case 'octUp':\n            if (isDown) {\n                oct = Math.min(oct + 1, 10);\n            }\n            break;\n\n        case 'velDown':\n            if (isDown) {\n                vel = Math.max(vel - 8, 7);\n            }\n            break;\n\n        case 'velUp':\n            if (isDown) {\n                vel = Math.min(vel + 8, 127);\n            }\n            break;\n\n        default:\n            const note = key + oct * 12;\n            if (note > 128)\n                return false;\n\n            if (isDown) {\n                currentKey = key;\n                connection.sendNoteOn(note, vel);\n\n            } else if (key === currentKey) {\n                connection.sendNoteOff();\n            }\n            break;\n    }\n\n    return true;\n}\n\nexport function setup(connection_) {\n    connection = connection_;\n\n    Settings.onChange('virtualKeyboard', value => {\n        enabled = value;\n        connection.sendNoteOff();\n    });\n}\n\n"
  },
  {
    "path": "js/main.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { UsbConnection } from './usb.js';\nimport { SerialConnection } from './serial.js';\nimport { Parser } from './parser.js';\nimport { Renderer as OldRenderer } from './renderer.js';\nimport { Renderer as GlRenderer } from './gl-renderer.js';\nimport { show, hide, toggle, appendButton, on } from './util.js';\nimport { setup as setupWorker } from './worker-setup.js';\n\nimport * as Input from './input.js';\nimport * as Audio from './audio.js';\nimport * as Settings from './settings.js';\nimport * as Firmware from './firmware.js';\nimport * as Wake from './wake.js';\n\nfunction setBackground(r, g, b) {\n    const colour = `rgb(${r}, ${g}, ${b})`;\n    document.body.style.backgroundColor = colour;\n    document.documentElement.style.backgroundColor = colour;\n    Settings.save('background', [r, g, b]);\n}\nconst bg = Settings.load('background', [0, 0, 0]);\nsetBackground(bg[0], bg[1], bg[2]);\nconst renderer = Settings.get('displayType') === 'webgl2'\n    ? new GlRenderer(bg, setBackground)\n    : new OldRenderer(bg, setBackground);\n\nconst parser = new Parser(renderer);\n\nlet resizeCanvas = (function() {\n    const display = document.getElementById('display');\n    const canvas = document.getElementById('canvas');\n\n    function resize() {\n        const ratio = devicePixelRatio;\n        const dW = display.clientWidth * ratio;\n        const svg = document.getElementById('screen');\n\n        if (Settings.get('snapPixels') && dW <= 1600) {\n            let dH = display.clientHeight * ratio;\n            if (Settings.get('showControls') || Input.isMapping) {\n                dH /= 2;\n            }\n\n            const width = Math.floor(dW / 320) * 320 / ratio;\n            const height = Math.floor(dH / 240) * 240 / ratio;\n            const left = Math.round((dW / ratio - width) / 2);\n            const top = Math.round((dH / ratio - height) / 2);\n\n            canvas.style.width = `${width}px`;\n            canvas.style.height = `${height}px`;\n            canvas.style.left = `${left}px`;\n            canvas.style.top = `${top}px`;\n\n            if (svg) {\n                svg.style.width = `${width}px`;\n                svg.style.height = `${height}px`;\n                svg.style.left = `${left}px`;\n                svg.style.top = `${top}px`;\n            }\n        } else {\n            canvas.style.width = null;\n            canvas.style.height = null;\n            canvas.style.left = null;\n            canvas.style.top = null;\n\n            if (svg) {\n                svg.style.width = null;\n                svg.style.height = null;\n                svg.style.left = null;\n                svg.style.top = null;\n            }\n        }\n    }\n\n    on(window, 'resize', resize);\n    window.matchMedia('screen and (min-resolution: 2dppx)')\n        .addListener(resize);\n\n    resize();\n\n    return resize;\n})();\n\nSettings.onChange('showControls', value => {\n    document\n        .getElementById('display')\n        .classList\n        .toggle('with-controls', value);\n    resizeCanvas();\n});\n\nSettings.onChange('enableAudio', value => {\n    if (value) { Audio.enable(); }\n    else { Audio.disable(); }\n});\n\nSettings.onChange('snapPixels', () => resizeCanvas());\n\nSettings.onChange('controlMapping', () => {\n    hide('#info');\n    Input.startMapping().then(resizeCanvas);\n    resizeCanvas();\n});\n\nSettings.onChange('firmware', () => {\n    hide('#info');\n    Firmware.open();\n});\n\nSettings.onChange('fullscreen', () => {\n    if (document.fullscreenElement) {\n        document.exitFullscreen();\n    } else {\n        document.body.requestFullscreen();\n    }\n});\n\nSettings.onChange('about', () => show('#info'));\n\nfunction connectionChanged(isConnected) {\n    if (isConnected) {\n        hide('#buttons, .error, #info');\n        Audio.start(10);\n\n    } else {\n        renderer.clear();\n        show('#buttons');\n        Audio.stop();\n    }\n\n    Wake.connectionChanged(isConnected);\n}\n\nif (navigator.serial) {\n    setupConnection(\n        new SerialConnection(parser, connectionChanged),\n        '#serial-fail');\n\n} else if (navigator.usb) {\n    setupConnection(\n        new UsbConnection(parser, connectionChanged),\n        '#usb-fail');\n\n} else {\n    show('#no-serial-usb');\n}\n\nfunction setupConnection(connection, errorMessage) {\n    Input.setup(connection);\n\n    on('#connect', 'click', () =>\n        connection.connect()\n            .catch(() => {\n                hide('#info');\n                show(errorMessage);\n            }));\n\n    on(window, 'beforeunload', e =>\n        connection.disconnect());\n\n    connection.connect(true).catch(() => {});\n}\n\non('#info button', 'click', () => hide('#info'));\n\nsetupWorker();\n"
  },
  {
    "path": "js/parser.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst NORMAL = Symbol('normal');\nconst ESCAPE = Symbol('escape');\nconst ERROR = Symbol('error');\n\nconst EMPTY = new Uint8Array(0);\n\nexport class Parser {\n    _state = NORMAL;\n    _buffer = new Uint8Array(512);\n    _i = 0;\n    _renderer;\n\n    constructor(renderer) {\n        this._renderer = renderer;\n    }\n\n    _processFrame(frame) {\n        switch (frame[0]) {\n            case 0xfe:\n                if (frame.length === 12) {\n                    this._renderer.drawRect(\n                        frame[1] + frame[2] * 256,\n                        frame[3] + frame[4] * 256,\n                        frame[5] + frame[6] * 256,\n                        frame[7] + frame[8] * 256,\n                        frame[9],\n                        frame[10],\n                        frame[11]);\n\n                } else {\n                    console.log('Bad RECT frame');\n                }\n                break;\n\n            case 0xfd:\n                if (frame.length === 12) {\n                    this._renderer.drawText(\n                        frame[1],\n                        frame[2] + frame[3] * 256,\n                        frame[4] + frame[5] * 256,\n                        frame[6],\n                        frame[7],\n                        frame[8]);\n\n                } else {\n                    console.log('Bad TEXT frame');\n                }\n                break;\n\n            case 0xfc: // wave\n                if (frame.length === 4) {\n                    this._renderer.drawWave(\n                        frame[1],\n                        frame[2],\n                        frame[3],\n                        EMPTY);\n\n                } else if (frame.length <= 324) {\n                    this._renderer.drawWave(\n                        frame[1],\n                        frame[2],\n                        frame[3],\n                        frame.subarray(4));\n\n                } else {\n                    console.log('Bad WAVE frame');\n                }\n                break;\n\n            case 0xfb: // joypad\n                if (frame.length !== 3) {\n                    console.log('Bad JPAD frame');\n                }\n                break;\n\n            case 0xff: // system\n                this._renderer.setFont(frame[5]);\n                break;\n            default:\n                console.log('BAD FRAME');\n        }\n    }\n\n    process(data) {\n        for (let i = 0; i < data.length; i++) {\n            const b = data[i];\n\n            switch (this._state) {\n                case NORMAL:\n                    switch (b) {\n                        case 0xc0:\n                            this._processFrame(this._buffer.subarray(0, this._i));\n                            this._i = 0;\n                            break;\n\n                        case 0xdb:\n                            this._state = ESCAPE;\n                            break;\n\n                        default:\n                            this._buffer[this._i++] = b;\n                            break;\n                    }\n                    break;\n\n                case ESCAPE:\n                    switch (b) {\n                        case 0xdc:\n                            this._buffer[this._i++] = 0xc0;\n                            this._state = NORMAL;\n                            break;\n\n                        case 0xdd:\n                            this._buffer[this._i++] = 0xdb;\n                            this._state = NORMAL;\n                            break;\n\n                        default:\n                            this._state = ERROR;\n                            console.log('Unexpected SLIP sequence');\n                            break;\n                    }\n                    break;\n\n                case ERROR:\n                    switch (b) {\n                        case 0xc0:\n                            this._state = NORMAL;\n                            this._i = 0;\n                            console.log('SLIP recovered');\n                            break;\n\n                        default:\n                            break;\n                    }\n            }\n        }\n    }\n\n    reset() {\n        this._state = NORMAL;\n        this._i = 0;\n    }\n}\n"
  },
  {
    "path": "js/renderer.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nexport class Renderer {\n    _canvas;\n    _ctx;\n    _textNodes = [];\n    _backgroundColour = 'rgb(0, 0, 0)';\n\n    _frameQueued = false;\n    _rects = [];\n    _waveColour = 'rgb(255, 255, 255)';\n    _waveData = new Uint8Array(320);\n    _waveOn = false;\n    _textUpdates = {};\n\n    _onBackgroundChanged;\n\n    _fontConfig = [\n        //glyph x, y, hoffset, voffset\n        [8,10,0,0],\n        [10,12,0,-40]\n    ];\n    \n    _fontId = 0;\n\n    constructor(bg, onBackgroundChanged) {\n        this._backgroundColour = `rgb(${bg[0]}, ${bg[1]}, ${bg[2]})`;\n        this._onBackgroundChanged = onBackgroundChanged;\n        this._canvas = document.getElementById('canvas');\n        this._ctx = canvas.getContext('2d');\n\n        this._buildText();\n    }\n\n    setFont(f) {\n        if(this._fontId == f) return;\n        this._fontId = f;   \n        this._buildText();\n        this.clear();\n    }\n\n    _buildText() {\n        const xmlns = 'http://www.w3.org/2000/svg';\n        const svg = document.createElementNS(xmlns, 'svg');\n        const canvas = document.getElementById('canvas');\n        svg.setAttributeNS(null, 'viewBox', '0 0 640 480');\n        svg.setAttributeNS(null, 'id', 'screen');\n        svg.setAttributeNS(null, 'style', canvas.getAttribute('style'));\n\n        if(this._fontId == 1) {\n            svg.setAttributeNS(null, 'class', 'big');\n        }\n\n        while (svg.firstChild) {\n            svg.removeChild(svg.lastChild);\n        }\n\n        var start = 0;\n        if(this._fontId == 1) { \n            start = 3;\n        }\n\n        for (let y = start; y < 25; y++) {\n            for (let x = 0; x < 39; x++) {\n                const e = document.createElementNS(xmlns, 'text');\n                const x_offset = x * (this._fontConfig[this._fontId][0] * 2);\n\n                var y_offset = 0;\n                if(this._fontId == 1) {\n                    y_offset = ((y - 3) * (this._fontConfig[this._fontId][1] * 2))+(this._fontConfig[this._fontId][1] * 2) - 16;\n                    if(y == 3) {\n                        y_offset += 20;\n                    }\n                } else {\n                    y_offset = (y * (this._fontConfig[this._fontId][1] * 2))+(this._fontConfig[this._fontId][1] * 2);\n                }\n\n                \n                if(this._fontId == 1) {\n                    y_offset += 10;\n                }\n            \n                e.setAttributeNS(null, 'x', x_offset);\n                e.setAttributeNS(null, 'y', y_offset);\n                e.setAttributeNS(null, 'fill', '_000');\n                const t = document.createTextNode('');\n                e.appendChild(t);\n                svg.appendChild(e);\n\n                this._textNodes[y * 39 + x] = {\n                    node: t,\n                    char: 32,\n                    fill: '_000'\n                };\n            }\n        }\n        if (document.contains(document.getElementById('screen'))) {\n            document.getElementById('screen').remove();\n        }\n        this._canvas.insertAdjacentElement('afterend', svg);\n    }\n\n    _renderFrame() {\n        for (let i = 0; i < this._rects.length; i++) {\n            const rect = this._rects[i];\n            this._ctx.fillStyle = rect.colour;\n            this._ctx.fillRect(rect.x, rect.y, rect.w, rect.h);\n        }\n        this._rects.length = 0;\n\n        if (this._waveUpdated) {\n            this._ctx.fillStyle = this._backgroundColour;\n            this._ctx.fillRect(0, 0, 320, 21);\n\n            if (this._waveOn) {\n                this._ctx.fillStyle = this._waveColour;\n                for (let i = 0; i < this._waveData.length; i++) {\n                    if(this._waveData[i] == 255) continue;\n                    const y = Math.min(this._waveData[i], 20);\n                    this._ctx.fillRect(i, y, 1, 1);\n                }\n            }\n        }\n        this._waveUpdated = false;\n\n        for (const [_, update] of Object.entries(this._textUpdates)) {\n            const node = update.node;\n            if (update.char !== node.char) {\n                node.node.nodeValue = String.fromCharCode(update.char);\n                node.char = update.char;\n            }\n            if (update.fill !== node.fill) {\n                node.node.parentElement.setAttributeNS(null, 'fill', update.fill);\n                node.fill = update.fill;\n            }\n        }\n        this._textUpdates = {};\n\n        this._frameQueued = false;\n    }\n\n    _queueFrame() {\n        if (!this._frameQueued) {\n            requestAnimationFrame(() => this._renderFrame());\n            this._frameQueued = true;\n        }\n    }\n\n    drawRect(x, y, w, h, r, g, b) {\n        const colour = `rgb(${r}, ${g}, ${b})`\n        if (x === 0 && y === 0 && w >= 320 && h >= 240) {\n            this._rects.length = 0;\n            this._backgroundColour = colour;\n            this._onBackgroundChanged(r, g, b);\n        }\n        if(this._fontId == 1) {\n            y += (this._fontConfig[this._fontId][3]);\n        }\n        this._rects.push({ colour, x, y, w, h });\n        this._queueFrame();\n    }\n\n    drawText(c, x, y, r, g, b) {\n        const i = Math.floor(y / this._fontConfig[this._fontId][1]) * 39 + Math.floor(x / this._fontConfig[this._fontId][0]);\n        if (this._textNodes[i]) {\n            this._textUpdates[i] = {\n                node: this._textNodes[i],\n                char: c,\n                fill: `rgb(${r}, ${g}, ${b})`\n            };\n            this._queueFrame();\n        }\n    }\n\n    drawWave(r, g, b, data) {\n        this._waveColour = `rgb(${r}, ${g}, ${b})`\n\n        if (data.length != 0) {\n            this._waveData.fill(-1);\n            this._waveData.set(data, 320-data.length);\n            this._waveOn = true;\n            this._waveUpdated = true;\n            this._queueFrame();\n\n        } else if (this._waveOn) {\n            this._waveOn = false;\n            this._waveUpdated = true;\n            this._queueFrame();\n        }\n    }\n\n    clear() {\n        this._rects = [{\n            colour: this._backgroundColour,\n            x: 0, y: 0, w: 320, h: 240,\n        }];\n\n        this._waveOn = false;\n        this._waveUpdated = true;\n\n        this._textUpdates = {};\n        for (let i = 0; i < this._textNodes.length; i++) {\n            this._textUpdates[i] = {\n                node: this._textNodes[i],\n                char: 32,\n                fill: `rgb(0, 0, 0)`\n            };\n\n        }\n\n        this._queueFrame();\n    }\n}\n"
  },
  {
    "path": "js/serial.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait, on } from './util.js';\n\nexport class SerialConnection {\n    _port;\n    _parser;\n    _onConnectionChanged;\n    _waitingForUserSelection;\n\n    constructor(parser, onConnectionChanged) {\n        this._parser = parser;\n        this._onConnectionChanged = onConnectionChanged;\n        this._waitingForUserSelection = false;\n\n        on(navigator.serial, 'connect', e => {\n            if (!this._waitingForUserSelection) {\n                this.connect(true).catch(() => {});\n            }\n        });\n    }\n\n    get isConnected() {\n        return !!this._port;\n    }\n\n    async _startReading() {\n        try {\n            while (this._port) {\n                const { value, done } = await this._port.reader.read();\n                if (value) {\n                    try {\n                        this._parser.process(value);\n                    } catch (err) {\n                        console.error(err);\n                    }\n                }\n\n                if (done)\n                    return;\n            }\n        } catch (err) {\n            console.error(err);\n            this.disconnect();\n        }\n    }\n\n    async _send(msg) {\n        if (!this._port || !this._port.writer)\n            return;\n\n        try {\n            await this._port.writer.write(new Uint8Array(msg));\n        } catch (err) {\n            console.error(err);\n            this.disconnect();\n        }\n    }\n\n    async sendKeys(state) {\n        this._send([0x43, state]);\n    }\n\n    async sendNoteOn(note, vel) {\n        this._send([0x4B, note, vel]);\n    }\n\n    async sendNoteOff() {\n        this._send([0x4B, 255]);\n    }\n\n    async _reset() {\n        await this._port.writer.write(new Uint8Array([0x44]));\n        await wait(50);\n        this._parser.reset();\n        await this._port.writer.write(new Uint8Array([0x45, 0x52]));\n    }\n\n    async disconnect() {\n        const port = this._port;\n        if (!port)\n            return;\n\n        this._port = null;\n\n        port.writer && await port.writer.write(new Uint8Array([0x44])).catch(() => {});\n        port.reader && await port.reader.cancel().catch(() => {});\n        await port.close().catch(() => {});\n\n        this._onConnectionChanged(false);\n    }\n\n    async connect(autoConnecting = false) {\n        if (this._port)\n            return;\n\n        try {\n            const ports = (await navigator.serial.getPorts())\n                .filter(p => {\n                    const info = p.getInfo();\n                    return info.usbVendorId === 0x16c0 &&\n                        info.usbProductId === 0x048a\n                });\n            this._port = ports.length === 1 ? ports[0] : null;\n\n            if (!this._port) {\n                if (autoConnecting) {\n                    this._onConnectionChanged(false);\n                } else {\n                    this._port = await this._requestPort();\n                }\n            }\n\n            if (!this._port)\n                return;\n\n            await this._port.open({\n                baudRate: 9600,\n                dataBits: 8,\n                stopBits: 1,\n                parity: 'none',\n                bufferSize: 4096\n            });\n\n            this._port.reader = await this._port.readable.getReader();\n            this._port.writer = await this._port.writable.getWriter();\n\n            await this._reset();\n            this._startReading();\n\n            this._onConnectionChanged(true);\n\n        } catch (err) {\n            console.error(err);\n            this.disconnect(err);\n            throw err;\n        }\n    }\n\n    async _requestPort() {\n        this._waitingForUserSelection = true;\n        try {\n            return await navigator.serial.requestPort({\n                filters: [{\n                    usbVendorId: 0x16c0,\n                    usbProductId: 0x048a\n                }]\n            });\n        } catch (err) {\n            if (err.code !== DOMException.NOT_FOUND_ERR) {\n                throw err;\n            } else {\n                return null;\n            }\n        } finally {\n            this._waitingForUserSelection = false;\n        }\n    }\n}\n\n"
  },
  {
    "path": "js/settings.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show, hide, toggle, appendButton, on } from './util.js';\n\non('#menu-button', 'click', () => toggle('#settings'));\n\non('#settings', 'click', e => {\n    if (e.target.id === 'settings') {\n        hide('#settings');\n    }\n});\n\nconst actions = {};\nconst values = {};\n\nsetupToggle('showControls', 'Show Controls', false);\nsetupToggle('hideMenu', 'Hide Menu', false);\nsetupToggle('enableAudio', 'Enable Audio', true);\n\nsetupSelect(\n    'displayType',\n    'Display Type',\n    { webgl2: 'WebGL2', old: 'Canvas + SVG' },\n    'webgl2');\n\nsetupToggle('snapPixels', 'Snap Pixels', true);\nsetupToggle('virtualKeyboard', 'Virtual Keyboard', true);\nsetupToggle('preventSleep', 'Prevent Sleep', false);\nsetupButton('controlMapping', 'Control Mapping');\nsetupButton('firmware', 'Load Firmware');\nsetupButton('fullscreen', 'Fullscreen');\nsetupButton('about', 'About');\n\nonChange('hideMenu', value => document\n    .getElementById('settings')\n    .classList\n    .toggle('auto-hide', value));\n\nfunction setupToggle(setting, title, defaultValue) {\n    const value = load(setting, defaultValue);\n\n    const div = document.createElement('div');\n    div.classList.add('setting');\n    const label = document.createElement('label');\n    label.innerText = title;\n    div.append(label);\n    const input = document.createElement('input');\n    input.setAttribute('type', 'checkbox');\n    input.checked = value;\n    label.append(input);\n\n    on(input, 'change', () =>\n        save(setting, input.checked));\n\n    document\n        .getElementById('settings')\n        .append(div);\n}\n\nfunction setupSelect(setting, title, options, defaultValue) {\n    const value = load(setting, defaultValue);\n\n    const div = document.createElement('div');\n    div.classList.add('setting');\n    const label = document.createElement('label');\n    label.innerText = title;\n    div.append(label);\n    const select = document.createElement('select');\n\n    for (const [value, title] of Object.entries(options)) {\n        const option = document.createElement('option');\n        option.value = value;\n        option.text = title;\n        select.append(option);\n    }\n    select.value = value;\n\n    label.append(select);\n\n    on(select, 'change', () =>\n        save(setting, select.value));\n\n    document\n        .getElementById('settings')\n        .append(div);\n}\n\nfunction setupButton(setting, title) {\n    const div = document.createElement('div');\n    div.classList.add('setting');\n    appendButton(div, title, () => {\n        hide('#settings');\n        actions[setting] && actions[setting]();\n    });\n\n    document\n        .getElementById('settings')\n        .append(div);\n}\n\nexport function load(setting, defaultValue) {\n    let value = localStorage[setting];\n    value = value === undefined ? defaultValue : JSON.parse(value);\n    values[setting] = value;\n\n    return value;\n}\n\nexport function save(setting, value) {\n    values[setting] = value;\n    actions[setting] && actions[setting](value);\n    localStorage[setting] = JSON.stringify(value);\n}\n\nexport function onChange(setting, action) {\n    actions[setting] = action;\n    if (get(setting) !== undefined) {\n        action(get(setting));\n    }\n}\n\nexport function get(setting) {\n    return values[setting];\n}\n"
  },
  {
    "path": "js/usb.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { wait, on } from './util.js';\n\nexport class UsbConnection {\n    _device;\n    _parser;\n    _onConnectionChanged;\n    _waitingForUserSelection;\n\n    constructor(parser, onConnectionChanged) {\n        this._parser = parser;\n        this._onConnectionChanged = onConnectionChanged;\n        this._waitingForUserSelection = false;\n\n        on(navigator.usb, 'connect', e => {\n            if (!this._waitingForUserSelection) {\n                this.connect(true).catch(() => {});\n            }\n        });\n    }\n\n    get isConnected() {\n        return !!this._device;\n    }\n\n    async _startReading() {\n        try {\n            while (this._device) {\n                const result = await this._device.transferIn(3, 512);\n                if (result.status !== 'ok') {\n                    this.disconnect();\n\n                } else {\n                    this._parser.process(new Uint8Array(result.data.buffer));\n                }\n            }\n        } catch (err) {\n            console.error(err);\n            this.disconnect();\n        }\n    }\n\n    async _send(msg) {\n        if (!this._device)\n            return;\n\n        try {\n            await this._device.transferOut(3, new Uint8Array(msg));\n        } catch (err) {\n            console.error(err);\n            this.disconnect();\n        }\n    }\n\n    async sendKeys(state) {\n        this._send([0x43, state]);\n    }\n\n    async sendNoteOn(note, vel) {\n        this._send([0x4B, note, vel]);\n    }\n\n    async sendNoteOff() {\n        this._send([0x4B, 255]);\n    }\n\n    async _reset() {\n        await this._device.transferOut(3, new Uint8Array([0x44]));\n        await wait(50);\n        this._parser.reset();\n        await this._device.transferOut(3, new Uint8Array([0x45, 0x52]));\n    }\n\n    async disconnect() {\n        const device = this._device;\n        if (!device)\n            return;\n\n        this._device = null;\n\n        await device.transferOut(3, new Uint8Array([0x44])).catch(() => {});\n        await device.close().catch(() => {});\n\n        this._onConnectionChanged(false);\n    }\n\n    async connect(autoConnecting = false) {\n        if (this._device)\n            return;\n\n        try {\n            const devices = (await navigator.usb.getDevices())\n                .filter(d =>\n                    d.vendorId === 0x16c0 &&\n                    d.productId === 0x048a);\n            this._device = devices.length === 1 ? devices[0] : null;\n\n            if (!this._device) {\n                if (autoConnecting) {\n                    this._onConnectionChanged(false);\n                } else {\n                    this._device = await this._requestDevice();\n                }\n            }\n\n            if (!this._device)\n                return;\n\n            await this._device.open();\n            await this._device.selectConfiguration(1);\n            await this._device.claimInterface(1);\n            await this._device.controlTransferOut(\n                {\n                    requestType: 'class',\n                    recipient: 'interface',\n                    request: 0x22,\n                    value: 0x03,\n                    index: 0x01\n                });\n            await this._device.controlTransferOut(\n                {\n                    requestType: 'class',\n                    recipient: 'interface',\n                    request: 0x20,\n                    value: 0x00,\n                    index: 0x01\n                },\n                new Uint8Array([0x80, 0x25, 0x00, 0x00, 0x00, 0x00, 0x08]));\n\n            await this._reset();\n            this._startReading();\n\n            this._onConnectionChanged(true);\n\n        } catch (err) {\n            console.error(err);\n            this.disconnect(err);\n            throw err;\n        }\n    }\n\n    async _requestDevice() {\n        this._waitingForUserSelection = true;\n        try {\n            return await navigator.usb.requestDevice({\n                filters: [{\n                    vendorId: 0x16c0,\n                    productId: 0x048a\n                }]\n            });\n        } catch (err) {\n            if (err.code !== DOMException.NOT_FOUND_ERR) {\n                throw err;\n            } else {\n                return null;\n            }\n        } finally {\n            this._waitingForUserSelection = false;\n        }\n    }\n}\n\n"
  },
  {
    "path": "js/util.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nexport function show(query) {\n    document\n        .querySelectorAll(query)\n        .forEach(e => e.classList.remove('hidden'));\n}\n\nexport function hide(query) {\n    document\n        .querySelectorAll(query)\n        .forEach(e => e.classList.add('hidden'));\n}\n\nexport function toggle(query) {\n    document\n        .querySelectorAll(query)\n        .forEach(e => e.classList.contains('hidden')\n            ? e.classList.remove('hidden')\n            : e.classList.add('hidden'));\n}\n\nexport function wait(time) {\n    return new Promise(resolve => setTimeout(resolve, time));\n}\n\nexport function appendButton(target, title, onClick) {\n    const button = document.createElement('button');\n    button.innerText = title;\n    on(button, 'click', onClick);\n\n    if (typeof target === 'string') {\n        target = document.querySelector(target)\n    }\n\n    target.append(button);\n\n    return button;\n}\n\nexport function on(target, eventType, action, useCapture) {\n    if (typeof target === 'string') {\n        target = document.querySelectorAll(target);\n    } else if (!(target instanceof Array)) {\n        target = [target];\n    }\n\n    for (const element of target) {\n        element.addEventListener(eventType, action, useCapture);\n    }\n}\n\nexport function off(target, eventType, action, useCapture) {\n    target.removeEventListener(eventType, action, useCapture);\n}\n"
  },
  {
    "path": "js/wake.js",
    "content": "// Copyright 2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { on } from './util.js';\nimport * as Settings from './settings.js';\n\nlet isConnected = false;\nlet wakeLock = null;\n\nexport function connectionChanged(isConnected_) {\n    isConnected = isConnected_;\n    updateLock();\n}\n\nSettings.onChange('preventSleep', () => updateLock());\non(document, 'visibilitychange', () => updateLock());\n\nasync function updateLock() {\n    if (!navigator.wakeLock)\n        return;\n\n    const shouldBeOn = isConnected &&\n        Settings.get('preventSleep') &&\n        document.visibilityState === 'visible';\n    const isOn = wakeLock && !wakeLock.released;\n\n    if (!shouldBeOn && isOn) {\n        wakeLock.release();\n        wakeLock = null;\n\n    } else if (shouldBeOn && !isOn) {\n        try {\n            wakeLock = await navigator.wakeLock.request('screen');\n            on(wakeLock, 'release', () => updateLock());\n        } catch {\n            wakeLock = null;\n        }\n    }\n}\n"
  },
  {
    "path": "js/worker-setup.js",
    "content": "// Copyright 2021-2022 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nimport { show, hide, on } from './util.js';\n\nconst updateInterval = 30 * 60 * 1000;\n\nlet reloading = false;\nfunction reload() {\n    if (!reloading) {\n        window.location.reload();\n        reloading = true;\n    }\n}\n\nlet reloadAction = () => {};\non('#reload button', 'click', () => reloadAction());\n\nexport async function setup() {\n    on(navigator.serviceWorker, 'controllerchange', () => reload());\n\n    let firstInstall = !navigator.serviceWorker.controller;\n\n    const reg = await navigator.serviceWorker.register('worker.js');\n    on(reg, 'updatefound', () => {\n        if (firstInstall) {\n            firstInstall = false;\n            return;\n        }\n\n        const newWorker = reg.installing;\n        on(newWorker, 'statechange', () => {\n            if (newWorker.state === 'installed') {\n                if (navigator.serviceWorker.controller) {\n                    reloadAction = () =>\n                        newWorker.postMessage({ action: 'skipWaiting' });\n\n                } else {\n                    reloadAction = reload;\n                }\n\n                show('#reload');\n            }\n        });\n    });\n\n    setInterval(() => reg.update(), updateInterval);\n}\n"
  },
  {
    "path": "js/worker.js",
    "content": "// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nconst cacheName = 'INDEXHASH';\n\nself.addEventListener('install', event => {\n    event.waitUntil(\n        caches.open(cacheName)\n            .then(cache => cache.addAll(['.', 'icon.png', 'app.webmanifest'])));\n});\n\nself.addEventListener('activate', event =>\n    event.waitUntil(\n        caches.keys()\n            .then(keys => Promise.all(keys\n                .filter(key => key !== cacheName)\n                .map(key => caches.delete(key))))));\n\nself.addEventListener('fetch', event =>\n    event.respondWith(\n        caches.match(event.request)\n            .then(response =>\n                response || fetch(event.request))));\n\nself.addEventListener('message', event => {\n    if (event.data.action === 'skipWaiting') {\n        self.skipWaiting();\n    }\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"dependencies\": {\n    \"juice\": \"^7.0.0\",\n    \"local-web-server\": \"^5.3.0\",\n    \"rollup\": \"^2.38.1\",\n    \"sass\": \"^1.32.5\",\n    \"terser\": \"^5.5.1\"\n  }\n}\n"
  },
  {
    "path": "shaders/blit.frag",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nprecision highp float;\n\nuniform sampler2D src;\n\nin vec2 srcCoord;\n\nout vec4 fragColour;\n\nvoid main() {\n    fragColour = texelFetch(src, ivec2(srcCoord), 0);\n}\n"
  },
  {
    "path": "shaders/blit.vert",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nout vec2 srcCoord;\n\nconst vec2 corners[] = vec2[](\n    vec2(0, 0),\n    vec2(0, 1),\n    vec2(1, 0),\n    vec2(1, 1));\n\nvoid main() {\n    vec2 pos = corners[gl_VertexID] * vec2(2.0, 2.0) + vec2(-1.0, -1.0);\n    gl_Position = vec4(pos, 0.0, 1.0);\n    srcCoord = corners[gl_VertexID] * vec2(320.0, 240.0);\n}\n"
  },
  {
    "path": "shaders/rect.frag",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nprecision highp float;\n\nin vec3 colourV;\n\nout vec4 fragColour;\n\nvoid main() {\n    fragColour = vec4(colourV, 1.0);\n}\n"
  },
  {
    "path": "shaders/rect.vert",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nlayout(location = 0) in vec4 shape;\nlayout(location = 1) in vec3 colour;\n\nout vec3 colourV;\n\nconst vec2 corners[] = vec2[](\n    vec2(0, 0),\n    vec2(0, 1),\n    vec2(1, 0),\n    vec2(1, 1));\n\nconst vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0);\nconst vec2 camOffset = vec2(-160.0, -120.0);\n\nvoid main() {\n    vec2 pos = shape.xy;\n    vec2 size = shape.zw;\n    pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale;\n\n    gl_Position = vec4(pos, 0.0, 1.0);\n    colourV = colour;\n}\n"
  },
  {
    "path": "shaders/text1.frag",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nprecision highp float;\n\nuniform sampler2D font;\n\nin vec2 fontCoord;\nin vec3 colourV;\n\nout vec4 fragColour;\n\nvoid main() {\n    vec4 fontTexel = texelFetch(font, ivec2(fontCoord), 0);\n    fragColour = vec4(colourV, fontTexel.r);\n}\n"
  },
  {
    "path": "shaders/text1.vert",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nlayout(location = 0) in vec3 colour;\nlayout(location = 1) in float char;\n\nout vec3 colourV;\nout vec2 fontCoord;\n\nconst vec2 corners[] = vec2[](\n    vec2(0, 0),\n    vec2(0, 1),\n    vec2(1, 0),\n    vec2(1, 1));\n\nconst vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0);\nconst vec2 camOffset = vec2(-160.0, -120.0);\nconst vec2 size = vec2(5.0, 7.0);\n\nvoid main() {\n    float row;\n    float col = modf(float(gl_InstanceID) / 40.0, row) * 40.0;\n\n    vec2 pos = vec2(col, row) * vec2(8.0, 10.0) + vec2(0.0, 3.0);\n\n    pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale;\n\n    gl_Position = vec4(char == 0.0 ? vec2(2.0) : pos, 0.0, 1.0);\n    colourV = colour;\n    fontCoord = (vec2(char - 1.0, 0.0) + corners[gl_VertexID]) * size;\n}\n"
  },
  {
    "path": "shaders/text2.frag",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nprecision highp float;\n\nuniform sampler2D font;\n\nin vec2 fontCoord;\nin vec3 colourV;\n\nout vec4 fragColour;\n\nvoid main() {\n    vec4 fontTexel = texelFetch(font, ivec2(fontCoord), 0);\n    fragColour = vec4(colourV, fontTexel.r);\n}\n"
  },
  {
    "path": "shaders/text2.vert",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nlayout(location = 0) in vec3 colour;\nlayout(location = 1) in float char;\n\nout vec3 colourV;\nout vec2 fontCoord;\n\nconst vec2 corners[] = vec2[](\n    vec2(0, 0),\n    vec2(0, 1),\n    vec2(1, 0),\n    vec2(1, 1));\n\nconst vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0);\nconst vec2 camOffset = vec2(-160.0, -120.0);\nconst vec2 size = vec2(8.0, 9.0);\n\nvoid main() {\n    float row;\n    float col = modf(float(gl_InstanceID) / 40.0, row) * 40.0;\n    row = row - 3.0;\n    vec2 pos = vec2(col, row) * vec2(10.0, 12.0) + vec2(0.0, 0.0);\n    \n    if(row == 0.0) {\n        pos = pos + vec2(0.0, 5.0); \n    }\n\n    pos = ((corners[gl_VertexID] * size + pos) + camOffset) * camScale;\n\n    gl_Position = vec4(char == 0.0 ? vec2(2.0) : pos, 0.0, 1.0);\n    colourV = colour;\n    fontCoord = (vec2(char - 1.0, 0.0) + corners[gl_VertexID]) * size;\n}\n"
  },
  {
    "path": "shaders/wave.frag",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nprecision highp float;\n\nuniform vec3 colour;\n\nout vec4 fragColour;\n\nvoid main() {\n    fragColour = vec4(colour, 1.0);\n}\n"
  },
  {
    "path": "shaders/wave.vert",
    "content": "#version 300 es\n// Copyright 2021 James Deery\n// Released under the MIT licence, https://opensource.org/licenses/MIT\n\nlayout(location = 0) in uint value;\n\nconst vec2 camScale = vec2(2.0 / 320.0, -2.0 / 240.0);\nconst vec2 camOffset = vec2(-160.0, -120.0);\n\nvoid main() {\n    vec2 pos = vec2(float(gl_VertexID), float(value));\n    pos = (pos + vec2(0.5) + camOffset) * camScale;\n\n    gl_PointSize = 1.0;\n    gl_Position = vec4(pos, 0.0, 1.0);\n}\n"
  }
]