diff --git a/ui/capture-button.js b/ui/capture-button.js new file mode 100644 index 0000000..4ae61b0 --- /dev/null +++ b/ui/capture-button.js @@ -0,0 +1,33 @@ +import { h, Component, Fragment } from 'preact'; + +/** @extends Component<{ getFile: () => Promise }, { inProgress: boolean }> */ +export class CaptureButton extends Component { + state = { inProgress: false }; + + handleCapture = async () => { + this.setState({ inProgress: true }); + let file = await this.props.getFile(); + let url = URL.createObjectURL(file); + Object.assign(document.createElement('a'), { + download: file.name, + href: url + }).click(); + URL.revokeObjectURL(url); + this.setState({ inProgress: false }); + }; + + render() { + return h( + Fragment, + null, + h('input', { + type: 'button', + id: 'capture', + class: + 'pure-button' + (this.state.inProgress ? ' pure-button-active' : ''), + onclick: this.handleCapture, + value: `${this.state.inProgress ? '⌛' : '📷'} Capture image` + }) + ); + } +} diff --git a/ui/index.js b/ui/index.js index a07bbbd..21fd9a1 100644 --- a/ui/index.js +++ b/ui/index.js @@ -1,10 +1,12 @@ -import { h, render, Component, Fragment } from 'preact'; -import { scheduleOp, start } from './ops.js'; +import { h, render, Component } from 'preact'; +import { CaptureButton } from './capture-button.js'; +import { connect } from './ops.js'; import { Preview } from './preview.js'; import { Widget } from './widget.js'; /** @typedef {import('../libapi.mjs').Context} Context */ /** @typedef {import('../libapi.mjs').Config} Config */ +/** @typedef {import('./ops').Connection} Connection */ let isDebug = new URLSearchParams(location.search).has('debug'); @@ -13,38 +15,6 @@ if (isDebug) { await import('preact/debug'); } -/** @extends Component */ -class CaptureButton extends Component { - state = { inProgress: false }; - - handleCapture = async () => { - this.setState({ inProgress: true }); - let file = await scheduleOp(context => context.captureImageAsFile()); - let url = URL.createObjectURL(file); - Object.assign(document.createElement('a'), { - download: file.name, - href: url - }).click(); - URL.revokeObjectURL(url); - this.setState({ inProgress: false }); - }; - - render() { - return h( - Fragment, - null, - h('input', { - type: 'button', - id: 'capture', - class: - 'pure-button' + (this.state.inProgress ? ' pure-button-active' : ''), - onclick: this.handleCapture, - value: `${this.state.inProgress ? '⌛' : '📷'} Capture image` - }) - ); - } -} - /** @typedef {{ type: 'CameraPicker' } | { type: 'Status', message: string } | { type: 'Config', config: Config }} AppState */ const INTERFACE_CLASS = 6; // PTP @@ -52,37 +22,28 @@ const INTERFACE_SUBCLASS = 1; // MTP /** @extends Component */ class App extends Component { - state = /** @type {AppState} */ ({ - type: 'Status', - message: 'Looking for cameras...' - }); + /** @type {Connection} */ + connection; - constructor(...args) { - super(...args); - // @ts-ignore - navigator.usb.getDevices().then(devices => { - for (let dev of devices) { - for (let conf of dev.configurations) { - for (let intf of conf.interfaces) { - for (let alt of intf.alternates) { - if ( - alt.interfaceClass === INTERFACE_CLASS && - alt.interfaceSubclass === INTERFACE_SUBCLASS - ) { - return this.connectToCamera(); - } - } - } - } - } - this.setState({ type: 'CameraPicker' }); - }); - addEventListener('error', ({ message }) => { + componentDidMount() { + addEventListener('error', ({ message }) => this.setState({ type: 'Status', message: `⚠ ${message}` - }); - }); + }) + ); + addEventListener( + 'beforeunload', + () => { + if (!this.connection) return; + this.connection.disconnect(); + this.connection = undefined; + }, + { once: true } + ); + // Try to connect to camera at startup. + // If none is found among saved connections, it will fallback to a picker. + this.tryToConnectToCamera(); } selectDevice = async () => { @@ -95,29 +56,32 @@ class App extends Component { } ] }); - this.connectToCamera(); + await this.tryToConnectToCamera(); }; - connectToCamera() { - start(); + async tryToConnectToCamera() { this.setState({ type: 'Status', message: 'Connecting...' }); - (async () => { - while (true) { - await this.refreshConfig(); - await new Promise(resolve => setTimeout(resolve, 100)); - } - })(); - } - - async refreshConfig() { - let config = await scheduleOp(context => context.configToJS()); - if (!isDebug) { - delete config.children.other; + try { + this.connection = await connect(); + } catch (e) { + console.warn(e); + this.setState({ type: 'CameraPicker' }); + return; + } + // We should reach this only once. + while (this.connection) { + let config = await this.connection.schedule(context => + context.configToJS() + ); + if (!isDebug) { + delete config.children.other; + } + this.setState({ + type: 'Config', + config + }); + await new Promise(resolve => setTimeout(resolve, 100)); } - this.setState({ - type: 'Config', - config - }); } /** @@ -128,7 +92,7 @@ class App extends Component { setValue = async (name, value) => { /** @type {Promise} */ let uiTimeout; - await scheduleOp(context => { + await this.connection.schedule(context => { // This is terrible, yes... but some configs return too quickly before they're actually updated. // We want to wait some time before updating the UI in that case, but not block subsequent ops. uiTimeout = new Promise(resolve => setTimeout(resolve, 800)); @@ -137,6 +101,12 @@ class App extends Component { await uiTimeout; }; + capturePreview = () => + this.connection.schedule(context => context.capturePreviewAsBlob()); + + captureImage = () => + this.connection.schedule(context => context.captureImageAsFile()); + render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) { switch (state.type) { case 'CameraPicker': @@ -164,8 +134,7 @@ class App extends Component { 'div', { class: 'pure-u-2-3' }, h(Preview, { - getPreview: () => - scheduleOp(context => context.capturePreviewAsBlob()) + getPreview: this.capturePreview }) ), h( @@ -174,7 +143,7 @@ class App extends Component { h( 'form', { class: 'pure-form pure-form-aligned' }, - h(CaptureButton, null), + h(CaptureButton, { getFile: this.captureImage }), h(Widget, { config: state.config, setValue: this.setValue }) ) ) diff --git a/ui/ops.js b/ui/ops.js index 88585c2..fc07455 100644 --- a/ui/ops.js +++ b/ui/ops.js @@ -2,43 +2,39 @@ import initModule from '../libapi.mjs'; /** @typedef {import('../libapi.mjs').Context} Context */ -/** This function should be called once user has selected the camera and other operations can begin. */ -export let start; +const ContextPromise = initModule().then(Module => Module.Context); -let started = new Promise(resolve => { - start = resolve; -}); +export async function connect() { + let Context = await ContextPromise; -let queue = (async () => { - let { Context } = await initModule(); - await started; - let ctx = await new Context(); - addEventListener('beforeunload', e => { - ctx.delete(); - }); - return ctx; -})(); + let context = await new Context(); -/** Schedules an exclusive async operation on the global context. - * @template T - * @param {(ctx: Context) => Promise} op - * @returns {Promise} - */ -export function scheduleOp(op) { - let caughtRes = queue.then(async ctx => { - let resultPromise = op(ctx); - try { - await resultPromise; - } catch {} - return { - ctx, - resultPromise - }; - }); + let queue = Promise.resolve(); - // Queue should ignore result values as well as errors from singular ops. - queue = caughtRes.then(res => res.ctx); + /** Schedules an exclusive async operation on the global context. + * @template T + * @param {(ctx: Context) => Promise} op + * @returns {Promise} + */ + function schedule(op) { + let res = queue.then(() => op(context)); - // Result should contain the unwrapped value or error. - return caughtRes.then(res => res.resultPromise); + // Queue should ignore result values as well as errors from singular ops. + queue = res.then( + () => {}, + () => {} + ); + + // Result should contain the unwrapped value or error. + return res; + } + + return { + schedule, + disconnect() { + context.delete(); + } + }; } + +/** @typedef {ReturnType extends Promise ? Connection : never} Connection */