Files
web-dslr/examples/preact/index.js
Jon 55958ae206
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a theme toggle, and fix the voice picker showing no voices
Theme toggle sits next to the settings icon. The choice starts as 'system' and
follows the OS until you press it, after which it's explicit and persisted.

An inline script in <head> stamps the resolved theme on <html> before the
first paint - resolving it from JS after load means a visible flash of dark on
the way to light. Because that attribute is always present, the stylesheet
drops its prefers-color-scheme query entirely rather than having a media query
and an explicit override fighting over the same tokens.

The voice picker was effectively empty: Chrome reports zero voices
synchronously and only fills the list when voiceschanged fires, and while the
narrator did reload them, nothing told preact to re-render - so the dropdown
kept whatever existed at construction, which was nothing. It now notifies, and
the list arrives (181 voices here).

That many voices needs shape, so they're sorted with your own language first
and offline voices ahead of network ones, then grouped into optgroups by
language. Network voices are marked as such since they're useless offline,
which for an app built to work in a field matters.

Speed and pitch are adjustable too, both fed through to every utterance.

The settings button gains a class of its own: the header now has two icon
buttons, so identifying it by .icon-button alone hits the theme toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-02 20:17:32 +01:00

624 lines
19 KiB
JavaScript

/*
* Copyright 2021 Google LLC
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
import { h, hydrate, Component, Fragment, createRef } from 'preact';
import { Camera, rethrowIfCritical } from 'web-gphoto2';
import { Preview } from './preview.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';
import { detectBulbSupport, configValue } from './config-utils.js';
import { Narrator, supportsSpeech } from './voice.js';
export const isDebug = new URLSearchParams(location.search).has('debug');
if (isDebug) {
// @ts-ignore
await import('preact/debug');
}
/** @param {number} ms */
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const systemPrefersLight = () => matchMedia('(prefers-color-scheme: light)');
/**
* Turn the stored preference into the theme actually in force.
* @param {'system' | 'light' | 'dark'} choice
*/
const resolveTheme = choice =>
choice === 'light' || choice === 'dark'
? choice
: systemPrefersLight().matches
? 'light'
: 'dark';
/** @extends Component<{}, AppState> */
class App extends Component {
/** @type {Camera | undefined} */
camera;
saver = new FrameSaver();
wakeLock = new WakeLock();
narrator = new Narrator();
intervalometer = new Intervalometer({
capture: index => this.captureFrame(index),
onChange: seq => this.handleSequenceChange(seq),
log: (message, kind) => this.log(message, kind)
});
// Make sure that first render hydrates the existing HTML smoothly.
/** @type {AppState} */
state = {
view: 'status',
message: '⌛ Loading…',
prefs: loadPrefs(),
seq: this.intervalometer.state,
log: [],
settingsOpen: false,
singleShotStatus: 'idle'
};
#logId = 0;
#configWatchToken = 0;
#wasRunning = false;
viewerRef = createRef();
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
// The voice list arrives after construction; re-render when it does.
this.narrator.onVoicesChanged = () => this.forceUpdate();
// 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);
this.applyTheme(this.state.prefs.theme);
// While the choice is 'system', follow the OS if it changes underneath us.
systemPrefersLight().addEventListener('change', () => {
if (this.state.prefs.theme === 'system') this.applyTheme('system');
});
addEventListener('error', ({ message }) =>
this.log(`Uncaught error: ${message}`, 'error')
);
// Closing the tab halfway through a long sequence is an expensive mistake.
addEventListener('beforeunload', e => {
if (this.intervalometer.running) {
e.preventDefault();
e.returnValue = '';
}
});
addEventListener(
'pagehide',
() => {
if (!this.camera) return;
this.intervalometer.stop();
this.camera.disconnect();
this.camera = undefined;
},
{ once: true }
);
// 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();
}
/**
* @param {string} message
* @param {'info' | 'warn' | 'error'} [kind]
*/
log(message, kind = 'info') {
if (kind === 'error') console.error(message);
this.setState(({ log }) => ({
log: [
{ id: ++this.#logId, message, kind, at: Date.now() },
...log
].slice(0, 8)
}));
}
/** @param {'system' | 'light' | 'dark'} choice */
applyTheme(choice) {
let theme = resolveTheme(choice);
document.documentElement.dataset.theme = theme;
// Keeps the PWA title bar and mobile browser chrome in step.
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme === 'light' ? '#ffffff' : '#14161a');
this.setState({ theme });
}
toggleTheme = () => {
let next = /** @type {'light' | 'dark'} */ (
resolveTheme(this.state.prefs.theme) === 'dark' ? 'light' : 'dark'
);
this.applyTheme(next);
this.setPref({ theme: next });
};
/** @param {import('./settings.js').Prefs} prefs */
syncNarrator(prefs) {
Object.assign(this.narrator, {
enabled: prefs.voice,
countFrom: prefs.voiceCountFrom,
voiceURI: prefs.voiceURI,
announceFrames: prefs.voiceAnnounceFrames,
rate: prefs.voiceRate,
pitch: prefs.voicePitch
});
}
/** @param {Partial<import('./settings.js').Prefs>} patch */
setPref = patch => {
this.setState(({ prefs }) => {
let next = { ...prefs, ...patch };
savePrefs(next);
this.saver.mode = next.saveMode;
this.syncNarrator(next);
if (next.keepAwake) this.wakeLock.acquire();
else this.wakeLock.release();
return { prefs: next };
});
};
toggleVoice = () => {
let on = !this.state.prefs.voice;
// Applied straight away rather than waiting for the state update, so a
// countdown can't slip out between the click and the commit.
this.narrator.enabled = on;
this.setPref({ voice: on });
// Speaking here also gets the user gesture Chrome wants before it will
// let a page talk at all.
if (on) this.narrator.say('Voice countdown on');
else this.narrator.cancel();
};
testVoice = () => {
let wasEnabled = this.narrator.enabled;
this.narrator.enabled = true;
this.narrator.say('Three. Two. One.', { interrupt: true });
this.narrator.enabled = wasEnabled;
};
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
this.setState({ view: 'status', message: '⌛ Connecting…' });
await this.tryToConnectToCamera();
};
async tryToConnectToCamera() {
/** @type {Camera} */
let camera;
try {
camera = new Camera();
await camera.connect();
} catch (e) {
console.warn(e);
this.setState({ view: 'picker' });
return;
}
this.camera = camera;
let supportedOps = await camera.getSupportedOps();
this.setState({ view: 'ready', supportedOps });
await this.refreshConfig();
this.log(
`Connected to ${configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model') ||
'camera'}.`
);
}
async refreshConfig() {
if (!this.camera) return;
try {
this.setState({ config: await this.camera.getConfig() });
} catch (err) {
rethrowIfCritical(err);
console.error('Could not refresh config:', err);
}
}
/**
* Poll the camera for changes made on the body itself (dial turns, etc).
*
* Unlike the original demo this only runs while the settings drawer is open:
* an unattended timelapse doesn't benefit from a constant stream of config
* reads, and every one of them is a USB round-trip competing with captures.
*/
async watchConfig() {
let token = ++this.#configWatchToken;
while (this.camera && token === this.#configWatchToken) {
await new Promise(resolve =>
requestIdleCallback(resolve, { timeout: 1000 })
);
if (!this.state.settingsOpen) break;
if (this.intervalometer.running) continue;
try {
if (await this.camera.consumeEvents()) {
await this.refreshConfig();
}
} catch (err) {
rethrowIfCritical(err);
console.error('Could not consume events:', err);
}
}
}
toggleSettings = async () => {
let settingsOpen = !this.state.settingsOpen;
this.setState({ settingsOpen });
if (settingsOpen) {
await this.refreshConfig();
this.watchConfig();
} else {
this.#configWatchToken++;
}
};
/**
* Set the specified config value, then re-read the tree.
*
* Setting one value often changes others (and the camera may round or reject
* what you asked for), so don't wait for the event loop to notice - it only
* runs while this drawer is open, and not at all mid-sequence.
*
* @param {string} name
* @param {*} value
*/
setValue = async (name, value) => {
if (!this.camera) return;
await this.camera.setConfigValue(name, value);
await this.refreshConfig();
};
get bulbSupport() {
return detectBulbSupport(this.state.config);
}
/**
* One frame of the sequence.
* @param {number} index
*/
async captureFrame(index) {
if (!this.camera) throw new Error('Camera is not connected');
if (this.state.prefs.bulbEnabled && this.bulbSupport) {
return this.bulbExposure();
}
let file = await this.camera.captureImageAsFile();
await this.saver.save(file, index);
}
/**
* Hold the shutter open for the configured duration.
*
* The resulting frame is written by the camera to its own storage - the WASM
* API only hands back files produced by an explicit `captureImageAsFile`, so
* there's nothing for us to download here.
*/
async bulbExposure() {
let support = this.bulbSupport;
if (!support) throw new Error('Bulb is not supported by this camera');
let { bulbSeconds } = this.state.prefs;
let open = () =>
support.kind === 'bulb'
? this.setValue('bulb', true)
: this.setValue('eosremoterelease', support.press);
let close = () =>
support.kind === 'bulb'
? this.setValue('bulb', false)
: this.setValue('eosremoterelease', support.release);
await open();
try {
await wait(bulbSeconds * 1000);
} finally {
await close();
}
}
/** @param {import('./intervalometer.js').SequenceState} seq */
handleSequenceChange(seq) {
let running = this.intervalometer.running;
this.narrator.update(seq);
this.setState({ seq });
if (this.#wasRunning && !running) {
// 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();
this.log(`Saving frames to “${name}”.`);
this.forceUpdate();
} catch (err) {
if (/** @type {Error} */ (err).name !== 'AbortError') {
this.log(`Could not open that folder: ${err}`, 'error');
}
}
};
startSequence = async () => {
let { prefs } = this.state;
if (prefs.saveMode === 'folder') {
if (!this.saver.hasFolder) {
await this.chooseFolder();
if (!this.saver.hasFolder) return;
}
if (!(await this.saver.ensureWritable())) {
this.log('Write permission for the output folder was denied.', 'error');
return;
}
}
if (prefs.keepAwake) await this.wakeLock.acquire();
let count = prefs.unlimited ? 0 : prefs.shots;
this.log(
`${count || '∞'} frames, one every ${formatDuration(
prefs.intervalSeconds
)}${
prefs.startDelaySeconds
? `, starting in ${formatDuration(prefs.startDelaySeconds)}`
: ''
}.`
);
this.narrator.reset();
this.narrator.say('Starting');
this.intervalometer.start({
intervalMs: prefs.intervalSeconds * 1000,
count,
startDelayMs: prefs.startDelaySeconds * 1000
});
};
stopSequence = () => {
this.intervalometer.stop();
};
singleShot = async () => {
if (!this.camera) return;
this.setState({ singleShotStatus: 'busy' });
try {
let file = await this.camera.captureImageAsFile();
let saved = await this.saver.save(file);
this.log(
saved ? `📷 Saved ${saved}.` : `📷 Captured ${file.name} (not saved).`
);
} catch (err) {
rethrowIfCritical(err);
this.log(`Capture failed: ${/** @type {Error} */ (err).message}`, 'error');
} finally {
this.setState({ singleShotStatus: 'idle' });
this.refreshConfig();
}
};
renderHeader() {
let model =
configValue(this.state.config, 'cameramodel') ||
configValue(this.state.config, 'model');
return h(
'header',
{ id: 'app-header' },
h(
'div',
{ class: 'brand' },
h('span', { class: 'logo' }, '⏱'),
h(
'div',
null,
h('h1', null, 'Intervalometer'),
h('small', null, model ? String(model) : 'DSLR over WebUSB')
)
),
h(
'div',
{ class: 'header-actions' },
h(
'button',
{
type: 'button',
class: 'icon-button',
onclick: this.toggleTheme,
title:
this.state.theme === 'dark'
? 'Switch to light mode'
: 'Switch to dark mode'
},
this.state.theme === 'dark' ? '☀' : '☾'
),
h(
'button',
{
type: 'button',
class: 'icon-button settings-button',
onclick: this.toggleSettings,
title: 'Settings'
},
'⚙'
)
)
);
}
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
switch (state.view) {
case 'picker':
return h(
'div',
{ class: 'center' },
h('h1', null, '⏱ Intervalometer'),
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
h(
'button',
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
'🔍 Select camera'
),
h(
'p',
{ class: 'fine-print' },
'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' },
'web-gphoto2'
),
'.'
)
);
case 'ready': {
let running = this.intervalometer.running;
let previewSupported = state.supportedOps?.capturePreview;
let showPreview = state.prefs.livePreview && previewSupported;
return h(
Fragment,
null,
this.renderHeader(),
h(
'main',
null,
h(
'div',
{
id: 'viewer',
ref: this.viewerRef,
class: state.fullscreen ? 'is-fullscreen' : ''
},
showPreview
? h(Preview, {
getPreview: () => this.camera.capturePreviewAsBlob(),
// Between frames the feed stays up; it only steps aside for
// the shutter itself, which the camera needs it to anyway.
paused:
running &&
(state.prefs.liveViewBetweenFrames
? state.seq.phase === 'capturing'
: true),
pausedMessage: state.prefs.liveViewBetweenFrames
? '📸 Taking the shot…'
: 'Live view paused for the whole sequence'
})
: h(
'div',
{ class: 'center muted' },
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,
setPref: this.setPref,
state: state.seq,
running,
onStart: this.startSequence,
onStop: this.stopSequence,
onSingleShot: this.singleShot,
singleShotStatus: state.singleShotStatus,
config: state.config,
hasFolder: this.saver.hasFolder,
canCapture: !!state.supportedOps?.captureImage,
log: state.log,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice,
setValue: this.setValue
})
),
h(SettingsDrawer, {
open: state.settingsOpen,
onClose: this.toggleSettings,
prefs: state.prefs,
setPref: this.setPref,
config: state.config,
setValue: this.setValue,
folderName: this.saver.folderName,
chooseFolder: this.chooseFolder,
bulbSupport: this.bulbSupport,
locked: running,
voices: this.narrator.voices,
testVoice: this.testVoice
})
);
}
default:
return h('div', { class: 'center' }, state.message);
}
}
}
hydrate(h(App, null), document.body);