Split settings into tabs; exposure and focus on the home screen
Some checks failed
CI / build-and-deploy (push) Has been cancelled

Home screen gains shutter, aperture and ISO as dropdowns plus a focus stepper
(three nudge sizes each way, and AF) where the body drives focus over PTP.
Those three drop out of the read-only strip rather than being shown twice.

Everything is built from what the camera actually reports, so a body without
one of these controls doesn't get it rather than showing something that
silently fails. The 450D marks shutter and aperture readonly unless the mode
dial is somewhere they apply, so the dropdowns disable themselves and say why.
Focus uses the EOS manualfocusdrive steps and autofocusdrive, and warns when
live view is off, which Canon bodies generally require for focus commands.

The settings drawer is now tabbed: app settings stay on the first tab, and
each config section the camera reports gets its own. Empty sections are
dropped, and a selected section that disappears falls back to the first tab.
Tabs wrap rather than scroll - a scrolled-off tab is an undiscoverable one.

Reverts the automatic camera search added in the previous commit, restoring
the Select camera button. Auto-connect could park with no way to reach the
device chooser: WebUSB permissions are per-origin, so a grant on localhost
doesn't carry to the deployed copy, and a camera that's asleep or held by
another app never arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
Jon
2026-08-02 19:36:06 +01:00
parent baaa0b5472
commit c3cf5ddfbf
7 changed files with 473 additions and 123 deletions

View File

@@ -98,9 +98,9 @@ class App extends Component {
{ once: true }
);
// No connect button: just keep reaching for the camera from the moment
// the page loads.
this.autoConnect();
// Try the camera once at startup; if it isn't among the connections the
// browser already knows about, fall back to the picker.
this.tryToConnectToCamera();
}
/**
@@ -159,64 +159,23 @@ class App extends Component {
this.narrator.enabled = wasEnabled;
};
grantAccess = async () => {
try {
// @ts-ignore
await Camera.showPicker();
} catch (err) {
// Dismissing the chooser is not an error worth shouting about.
return;
}
await this.connectOnce();
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ view: 'status', message: '⌛ Connecting…' });
await this.tryToConnectToCamera();
};
/**
* Keep trying to attach to a camera the browser has already been given
* permission for, so landing on the page is all it takes.
*
* WebUSB will only show its device chooser from a user gesture, so the one
* case that genuinely can't be automated is the very first time - after that
* the permission is remembered and `getDevices()` sees the camera with no
* interaction at all.
*/
async autoConnect() {
// Reconnect the moment a known camera is plugged in or switched on.
// @ts-ignore
navigator.usb?.addEventListener?.('connect', () => this.connectOnce());
while (!this.camera) {
let permitted = await this.permittedDevices();
if (permitted.length) {
this.setState({ view: 'searching' });
if (await this.connectOnce()) return;
} else {
this.setState({ view: 'permission' });
}
await wait(1500);
}
}
async permittedDevices() {
try {
// @ts-ignore
return await navigator.usb.getDevices();
} catch {
return [];
}
}
/** @returns {Promise<boolean>} whether we're now connected. */
async connectOnce() {
if (this.camera) return true;
async tryToConnectToCamera() {
/** @type {Camera} */
let camera;
try {
camera = new Camera();
await camera.connect();
} catch (err) {
// Expected while the camera is off, asleep, or claimed by another app.
console.debug('Camera not ready yet:', err);
return false;
} catch (e) {
console.warn(e);
this.setState({ view: 'picker' });
return;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
@@ -227,7 +186,6 @@ class App extends Component {
configValue(this.state.config, 'model') ||
'camera'}.`
);
return true;
}
async refreshConfig() {
@@ -468,38 +426,21 @@ class App extends Component {
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.view) {
case 'searching':
case 'picker':
return h(
'div',
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', { class: 'searching' }, h('span', { class: 'spinner' }), 'Looking for your camera…'),
h(
'p',
{ class: 'fine-print' },
'Switch the camera on and set the mode dial off Auto. It will connect on its own — no need to reload. On macOS, quit Photos and Image Capture if they grabbed it first.'
)
);
case 'permission':
return h(
'div',
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h(
'p',
null,
'One-off setup: the browser needs your permission to talk to the camera.'
),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h(
'button',
{ type: 'button', class: 'primary big', onclick: this.grantAccess },
'🔍 Choose camera'
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
),
h(
'p',
{ class: 'fine-print' },
'Only needed once — after this it connects by itself whenever you open the page. Chrome will not open its device chooser without a click, which is the one part of this that cannot be automatic. Built on ',
'Requires Chrome with WebUSB. On macOS, quit Photos and Image Capture if they grabbed the camera first; on Linux you may need a udev rule. Built on ',
h(
'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
@@ -593,7 +534,8 @@ class App extends Component {
canCapture: !!state.supportedOps?.captureImage,
log: state.log,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice
onToggleVoice: this.toggleVoice,
setValue: this.setValue
})
),
h(SettingsDrawer, {