Extract CaptureButton; allow non-singletone connections

This commit is contained in:
Ingvar Stepanyan
2021-07-21 16:38:18 +00:00
parent 73148dbc11
commit 4371da9270
3 changed files with 116 additions and 118 deletions

33
ui/capture-button.js Normal file
View File

@@ -0,0 +1,33 @@
import { h, Component, Fragment } from 'preact';
/** @extends Component<{ getFile: () => Promise<File> }, { 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`
})
);
}
}

View File

@@ -1,10 +1,12 @@
import { h, render, Component, Fragment } from 'preact'; import { h, render, Component } from 'preact';
import { scheduleOp, start } from './ops.js'; import { CaptureButton } from './capture-button.js';
import { connect } from './ops.js';
import { Preview } from './preview.js'; import { Preview } from './preview.js';
import { Widget } from './widget.js'; import { Widget } from './widget.js';
/** @typedef {import('../libapi.mjs').Context} Context */ /** @typedef {import('../libapi.mjs').Context} Context */
/** @typedef {import('../libapi.mjs').Config} Config */ /** @typedef {import('../libapi.mjs').Config} Config */
/** @typedef {import('./ops').Connection} Connection */
let isDebug = new URLSearchParams(location.search).has('debug'); let isDebug = new URLSearchParams(location.search).has('debug');
@@ -13,38 +15,6 @@ if (isDebug) {
await import('preact/debug'); await import('preact/debug');
} }
/** @extends Component<null, { inProgress: boolean }> */
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 */ /** @typedef {{ type: 'CameraPicker' } | { type: 'Status', message: string } | { type: 'Config', config: Config }} AppState */
const INTERFACE_CLASS = 6; // PTP const INTERFACE_CLASS = 6; // PTP
@@ -52,37 +22,28 @@ const INTERFACE_SUBCLASS = 1; // MTP
/** @extends Component<null, AppState> */ /** @extends Component<null, AppState> */
class App extends Component { class App extends Component {
state = /** @type {AppState} */ ({ /** @type {Connection} */
type: 'Status', connection;
message: 'Looking for cameras...'
});
constructor(...args) { componentDidMount() {
super(...args); addEventListener('error', ({ message }) =>
// @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 }) => {
this.setState({ this.setState({
type: 'Status', type: 'Status',
message: `${message}` 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 () => { selectDevice = async () => {
@@ -95,22 +56,23 @@ class App extends Component {
} }
] ]
}); });
this.connectToCamera(); await this.tryToConnectToCamera();
}; };
connectToCamera() { async tryToConnectToCamera() {
start();
this.setState({ type: 'Status', message: 'Connecting...' }); this.setState({ type: 'Status', message: 'Connecting...' });
(async () => { try {
while (true) { this.connection = await connect();
await this.refreshConfig(); } catch (e) {
await new Promise(resolve => setTimeout(resolve, 100)); console.warn(e);
this.setState({ type: 'CameraPicker' });
return;
} }
})(); // We should reach this only once.
} while (this.connection) {
let config = await this.connection.schedule(context =>
async refreshConfig() { context.configToJS()
let config = await scheduleOp(context => context.configToJS()); );
if (!isDebug) { if (!isDebug) {
delete config.children.other; delete config.children.other;
} }
@@ -118,6 +80,8 @@ class App extends Component {
type: 'Config', type: 'Config',
config config
}); });
await new Promise(resolve => setTimeout(resolve, 100));
}
} }
/** /**
@@ -128,7 +92,7 @@ class App extends Component {
setValue = async (name, value) => { setValue = async (name, value) => {
/** @type {Promise<void>} */ /** @type {Promise<void>} */
let uiTimeout; 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. // 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. // 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)); uiTimeout = new Promise(resolve => setTimeout(resolve, 800));
@@ -137,6 +101,12 @@ class App extends Component {
await uiTimeout; 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) { render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.type) { switch (state.type) {
case 'CameraPicker': case 'CameraPicker':
@@ -164,8 +134,7 @@ class App extends Component {
'div', 'div',
{ class: 'pure-u-2-3' }, { class: 'pure-u-2-3' },
h(Preview, { h(Preview, {
getPreview: () => getPreview: this.capturePreview
scheduleOp(context => context.capturePreviewAsBlob())
}) })
), ),
h( h(
@@ -174,7 +143,7 @@ class App extends Component {
h( h(
'form', 'form',
{ class: 'pure-form pure-form-aligned' }, { class: 'pure-form pure-form-aligned' },
h(CaptureButton, null), h(CaptureButton, { getFile: this.captureImage }),
h(Widget, { config: state.config, setValue: this.setValue }) h(Widget, { config: state.config, setValue: this.setValue })
) )
) )

View File

@@ -2,43 +2,39 @@ import initModule from '../libapi.mjs';
/** @typedef {import('../libapi.mjs').Context} Context */ /** @typedef {import('../libapi.mjs').Context} Context */
/** This function should be called once user has selected the camera and other operations can begin. */ const ContextPromise = initModule().then(Module => Module.Context);
export let start;
let started = new Promise(resolve => { export async function connect() {
start = resolve; let Context = await ContextPromise;
});
let queue = (async () => { let context = await new Context();
let { Context } = await initModule();
await started; let queue = Promise.resolve();
let ctx = await new Context();
addEventListener('beforeunload', e => {
ctx.delete();
});
return ctx;
})();
/** Schedules an exclusive async operation on the global context. /** Schedules an exclusive async operation on the global context.
* @template T * @template T
* @param {(ctx: Context) => Promise<T>} op * @param {(ctx: Context) => Promise<T>} op
* @returns {Promise<T>} * @returns {Promise<T>}
*/ */
export function scheduleOp(op) { function schedule(op) {
let caughtRes = queue.then(async ctx => { let res = queue.then(() => op(context));
let resultPromise = op(ctx);
try {
await resultPromise;
} catch {}
return {
ctx,
resultPromise
};
});
// Queue should ignore result values as well as errors from singular ops. // Queue should ignore result values as well as errors from singular ops.
queue = caughtRes.then(res => res.ctx); queue = res.then(
() => {},
() => {}
);
// Result should contain the unwrapped value or error. // Result should contain the unwrapped value or error.
return caughtRes.then(res => res.resultPromise); return res;
} }
return {
schedule,
disconnect() {
context.delete();
}
};
}
/** @typedef {ReturnType<typeof connect> extends Promise<infer Connection> ? Connection : never} Connection */