Add fullscreen preview, PWA support, and connect without a button
Some checks failed
CI / build-and-deploy (push) Has been cancelled

Fullscreen: a button on the preview pane fullscreens the live view, carrying
the countdown over as an overlay - fullscreen hides the entire control column,
so without it you're left staring at a picture with no idea when the next
frame lands.

Wake lock: held from app start until you switch it off, rather than only for
the duration of a sequence. You're usually mid-setup when the display would
otherwise sleep.

PWA: manifest, icons and a service worker, so it installs to a standalone
window and runs with no network. That matters more here than for most web
apps - the camera is on a USB cable, so this is fully functional in a field
with no signal.

The worker precaches the pinned unpkg dependencies at install rather than
leaving them to the runtime cache. On a first visit the worker isn't
controlling the page yet, so the app's own imports go straight to the network
and never reach the fetch handler; without the precache it looked cached but
died offline on its imports. Our own files are network-first so a redeploy
always wins, and the version-pinned CDN files are cache-first.

Connecting: no connect button. The app reaches for the camera on load, retries
every 1.5s, and listens for USB connect events, so switching the camera on
mid-wait attaches it with no click or reload. The exception is a browser that
has never been granted access to the device: Chrome will not open its WebUSB
chooser outside a user gesture, so that case still shows a one-off prompt.
After that the permission is remembered and getDevices() finds the camera with
no interaction.

tsconfig excludes sw.js, which runs in ServiceWorkerGlobalScope and reports
every worker global as undefined when checked against the DOM lib.

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 09:53:24 +01:00
parent c293ee47d9
commit baaa0b5472
13 changed files with 474 additions and 69 deletions

View File

@@ -16,10 +16,10 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
import { h, hydrate, Component, Fragment } from 'preact';
import { h, hydrate, Component, Fragment, createRef } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.js';
import { Home } from './home.js';
import { Home, sequenceSummary } from './home.js';
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
import { Intervalometer, formatDuration } from './intervalometer.js';
import { FrameSaver, WakeLock } from './storage.js';
@@ -66,10 +66,16 @@ class App extends Component {
#logId = 0;
#configWatchToken = 0;
#wasRunning = false;
viewerRef = createRef();
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
// Held for as long as the app is open, not just while a sequence runs -
// you're usually mid-setup when the display would otherwise sleep.
if (this.state.prefs.keepAwake) this.wakeLock.acquire();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
addEventListener('error', ({ message }) =>
this.log(`Uncaught error: ${message}`, 'error')
@@ -92,9 +98,9 @@ class App extends Component {
{ once: true }
);
// Try to connect to camera at startup.
// If none is found among saved connections, it will fallback to a picker.
this.tryToConnectToCamera();
// No connect button: just keep reaching for the camera from the moment
// the page loads.
this.autoConnect();
}
/**
@@ -128,7 +134,8 @@ class App extends Component {
savePrefs(next);
this.saver.mode = next.saveMode;
this.syncNarrator(next);
if (!next.keepAwake) this.wakeLock.release();
if (next.keepAwake) this.wakeLock.acquire();
else this.wakeLock.release();
return { prefs: next };
});
};
@@ -152,23 +159,64 @@ class App extends Component {
this.narrator.enabled = wasEnabled;
};
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ view: 'status', message: '⌛ Connecting…' });
await this.tryToConnectToCamera();
grantAccess = async () => {
try {
// @ts-ignore
await Camera.showPicker();
} catch (err) {
// Dismissing the chooser is not an error worth shouting about.
return;
}
await this.connectOnce();
};
async 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;
/** @type {Camera} */
let camera;
try {
camera = new Camera();
await camera.connect();
} catch (e) {
console.warn(e);
this.setState({ view: 'picker' });
return;
} catch (err) {
// Expected while the camera is off, asleep, or claimed by another app.
console.debug('Camera not ready yet:', err);
return false;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
@@ -179,6 +227,7 @@ class App extends Component {
configValue(this.state.config, 'model') ||
'camera'}.`
);
return true;
}
async refreshConfig() {
@@ -295,12 +344,28 @@ class App extends Component {
this.narrator.update(seq);
this.setState({ seq });
if (this.#wasRunning && !running) {
this.wakeLock.release();
// The wake lock stays - it's app-wide now, not sequence-scoped.
this.refreshConfig();
}
this.#wasRunning = running;
}
handleFullscreenChange = () => {
this.setState({ fullscreen: !!document.fullscreenElement });
};
toggleFullscreen = async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.viewerRef.current?.requestFullscreen();
}
} catch (err) {
this.log(`Could not toggle fullscreen: ${err}`, 'warn');
}
};
chooseFolder = async () => {
try {
let name = await this.saver.chooseFolder();
@@ -403,21 +468,38 @@ class App extends Component {
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.view) {
case 'picker':
case 'searching':
return h(
'div',
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
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(
'button',
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
{ type: 'button', class: 'primary big', onclick: this.grantAccess },
'🔍 Choose camera'
),
h(
'p',
{ class: 'fine-print' },
'Requires Chrome with WebUSB. On Linux you may need a udev rule, and on macOS you may need to quit Photos/Image Capture first. Built on ',
'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 ',
h(
'a',
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
@@ -440,7 +522,11 @@ class App extends Component {
null,
h(
'div',
{ id: 'viewer' },
{
id: 'viewer',
ref: this.viewerRef,
class: state.fullscreen ? 'is-fullscreen' : ''
},
showPreview
? h(Preview, {
getPreview: () => this.camera.capturePreviewAsBlob(),
@@ -461,7 +547,37 @@ class App extends Component {
previewSupported
? 'Live view is turned off in Settings.'
: 'This camera does not support live preview.'
),
h(
'button',
{
type: 'button',
class: 'viewer-button',
onclick: this.toggleFullscreen,
title: state.fullscreen
? 'Exit fullscreen (Esc)'
: 'Fullscreen preview'
},
state.fullscreen ? '✕' : '⛶'
),
// Fullscreen hides the whole control column, so carry the
// countdown across rather than leaving you staring at a picture.
state.fullscreen
? h(
'div',
{ class: 'viewer-status' },
h(
'span',
{ class: 'viewer-status-headline' },
sequenceSummary(state.seq, state.prefs).headline
),
h(
'span',
{ class: 'viewer-status-detail' },
sequenceSummary(state.seq, state.prefs).detail
)
)
: undefined
),
h(Home, {
prefs: state.prefs,