Files
web-dslr/examples/preact/settings.js
Jon baaa0b5472
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add fullscreen preview, PWA support, and connect without a button
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
2026-08-02 09:53:24 +01:00

416 lines
14 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, Component, Fragment } from 'preact';
import { Widget } from './widget.js';
import { supportsFolderSaving } from './storage.js';
import { supportsSpeech } from './voice.js';
const PREFS_KEY = 'web-dslr.prefs';
export const DEFAULT_PREFS = {
// Home screen sequence parameters.
intervalSeconds: 10,
shots: 120,
unlimited: false,
startDelaySeconds: 0,
// Everything below lives in this drawer.
saveMode: /** @type {'folder' | 'download' | 'none'} */ (
supportsFolderSaving ? 'folder' : 'download'
),
livePreview: true,
// Renamed from `previewDuringSequence` (which defaulted to off) so saved
// prefs from before pick up the new default rather than the old behaviour.
liveViewBetweenFrames: true,
keepAwake: true,
bulbEnabled: false,
bulbSeconds: 30,
// Toggled from the home screen; the rest live in this drawer.
voice: false,
voiceCountFrom: 5,
voiceURI: '',
voiceAnnounceFrames: false
};
/** @typedef {typeof DEFAULT_PREFS} Prefs */
/** @returns {Prefs} */
export function loadPrefs() {
try {
let stored = localStorage.getItem(PREFS_KEY);
// Spread over the defaults so prefs added in a later version fill in.
return stored ? { ...DEFAULT_PREFS, ...JSON.parse(stored) } : { ...DEFAULT_PREFS };
} catch (err) {
console.warn('Could not read saved settings:', err);
return { ...DEFAULT_PREFS };
}
}
/** @param {Prefs} prefs */
export function savePrefs(prefs) {
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch (err) {
console.warn('Could not persist settings:', err);
}
}
/**
* @param {{ label: string, hint?: string, children?: any }} props
*/
function Row({ label, hint, children }) {
return h(
'div',
{ class: 'setting-row' },
h(
'div',
{ class: 'setting-label' },
h('span', null, label),
hint ? h('small', null, hint) : undefined
),
h('div', { class: 'setting-control' }, children)
);
}
/**
* Slide-over panel holding everything that isn't interval timing: where frames
* go, preview behaviour, bulb, and the camera's own config tree.
*
* @extends Component<{
* open: boolean,
* onClose: () => void,
* prefs: Prefs,
* setPref: (patch: Partial<Prefs>) => void,
* config: import('web-gphoto2').Config | undefined,
* setValue: (name: string, value: any) => Promise<void>,
* folderName: string | undefined,
* chooseFolder: () => void,
* bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>,
* locked: boolean,
* voices: SpeechSynthesisVoice[],
* testVoice: () => void
* }>
*/
export class SettingsDrawer extends Component {
#onKeyDown = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape' && this.props.open) this.props.onClose();
};
componentDidMount() {
addEventListener('keydown', this.#onKeyDown);
}
componentWillUnmount() {
removeEventListener('keydown', this.#onKeyDown);
}
render(/** @type {SettingsDrawer['props']} */ props) {
let {
open,
onClose,
prefs,
setPref,
config,
setValue,
folderName,
chooseFolder,
bulbSupport,
locked,
voices,
testVoice
} = props;
return h(
Fragment,
null,
h('div', {
class: `scrim ${open ? 'open' : ''}`,
onclick: onClose
}),
// Visibility (and therefore focus order / screen reader exposure) is
// driven by the `open` class in CSS rather than aria-hidden.
h(
'aside',
{
id: 'settings',
class: open ? 'open' : ''
},
h(
'header',
null,
h('h2', null, 'Settings'),
h(
'button',
{ type: 'button', class: 'icon-button', onclick: onClose, title: 'Close settings' },
'✕'
)
),
h(
'div',
{ class: 'drawer-body' },
locked
? h(
'p',
{ class: 'notice warn' },
'A sequence is running. Changing camera settings mid-run is allowed, but each change costs a USB round-trip and may delay a frame.'
)
: undefined,
h(
'section',
null,
h('h3', null, 'Where frames go'),
h(
Row,
{
label: 'Save frames to',
hint:
prefs.saveMode === 'none'
? 'Frames are still transferred off the camera, just not written to disk.'
: undefined
},
h(
'select',
{
value: prefs.saveMode,
onChange: e => setPref({ saveMode: e.currentTarget.value })
},
supportsFolderSaving
? h('option', { value: 'folder' }, 'A folder on this computer')
: undefined,
h('option', { value: 'download' }, 'Downloads (one file at a time)'),
h('option', { value: 'none' }, "Don't save in the browser")
)
),
prefs.saveMode === 'folder'
? h(
Row,
{
label: 'Output folder',
hint: 'Frames are prefixed with a zero-padded index so they sort in order.'
},
h(
'button',
{ type: 'button', class: 'secondary', onclick: chooseFolder },
folderName ? `📁 ${folderName}` : '📁 Choose folder…'
)
)
: undefined,
prefs.saveMode === 'none'
? h(
'p',
{ class: 'notice' },
'Set the camera\'s capture target to the memory card below if you want to keep the frames at all.'
)
: undefined
),
h(
'section',
null,
h('h3', null, 'Live view'),
h(
Row,
{
label: 'Show live preview',
hint: 'Live view on the 450D warms the sensor and drains the battery.'
},
h('input', {
type: 'checkbox',
checked: prefs.livePreview,
onChange: e => setPref({ livePreview: e.currentTarget.checked })
})
),
h(
Row,
{
label: 'Live view between frames',
hint: 'Keeps the feed up during a sequence, dropping it only while each shot fires. Turn off to leave the USB link entirely to the captures.'
},
h('input', {
type: 'checkbox',
checked: prefs.liveViewBetweenFrames,
disabled: !prefs.livePreview,
onChange: e =>
setPref({ liveViewBetweenFrames: e.currentTarget.checked })
})
),
h(
Row,
{
label: 'Keep screen awake',
hint: 'Held the whole time the app is open, not just during a sequence. Background tabs get their timers throttled, which ruins interval timing.'
},
h('input', {
type: 'checkbox',
checked: prefs.keepAwake,
disabled: !('wakeLock' in navigator),
onChange: e => setPref({ keepAwake: e.currentTarget.checked })
})
)
),
h(
'section',
null,
h('h3', null, 'Voice countdown'),
supportsSpeech
? h(
Fragment,
null,
h(
'p',
{ class: 'notice' },
'Switched on and off with the 🔊 button on the home screen.'
),
h(
Row,
{
label: 'Start counting at',
hint: 'Capped at one second less than the interval, so it never talks over the previous frame.'
},
h('input', {
type: 'number',
min: '1',
max: '30',
step: '1',
value: prefs.voiceCountFrom,
onChange: e =>
setPref({
voiceCountFrom: Math.min(
30,
Math.max(1, e.currentTarget.valueAsNumber || 1)
)
})
}),
h('span', { class: 'unit' }, 'sec')
),
h(
Row,
{ label: 'Announce frame number' },
h('input', {
type: 'checkbox',
checked: prefs.voiceAnnounceFrames,
onChange: e =>
setPref({ voiceAnnounceFrames: e.currentTarget.checked })
})
),
h(
Row,
{ label: 'Voice' },
h(
'select',
{
value: prefs.voiceURI,
onChange: e => setPref({ voiceURI: e.currentTarget.value })
},
h('option', { value: '' }, 'Browser default'),
voices.map(v =>
h('option', { key: v.voiceURI, value: v.voiceURI }, `${v.name} (${v.lang})`)
)
)
),
h(
Row,
{ label: 'Test' },
h(
'button',
{ type: 'button', class: 'secondary', onclick: testVoice },
'🔊 Say “three, two, one”'
)
)
)
: h(
'p',
{ class: 'notice' },
'This browser has no speech synthesis, so the voice countdown is unavailable.'
)
),
h(
'section',
null,
h('h3', null, 'Bulb exposures ', h('span', { class: 'tag' }, 'experimental')),
bulbSupport
? h(
Fragment,
null,
h(
Row,
{
label: 'Use bulb for each frame',
hint: `Driven via ${
bulbSupport.kind === 'bulb' ? 'the bulb toggle' : 'eosremoterelease'
}. Put the mode dial on B first.`
},
h('input', {
type: 'checkbox',
checked: prefs.bulbEnabled,
onChange: e => setPref({ bulbEnabled: e.currentTarget.checked })
})
),
h(
Row,
{ label: 'Exposure length' },
h('input', {
type: 'number',
min: '1',
step: '1',
value: prefs.bulbSeconds,
onChange: e =>
setPref({
bulbSeconds: Math.max(1, e.currentTarget.valueAsNumber || 1)
})
}),
h('span', { class: 'unit' }, 'sec')
),
prefs.bulbEnabled
? h(
'p',
{ class: 'notice warn' },
'Bulb frames are written to the camera card and are ',
h('strong', null, 'not'),
' downloaded to the browser — the WASM API has no hook for files that arrive outside of a normal capture. Set the capture target to the memory card and pull the card afterwards.'
)
: undefined
)
: h(
'p',
{ class: 'notice' },
'This camera does not expose a bulb or eosremoterelease control, so timed long exposures are unavailable. Use the shutter speed setting below instead (up to 30s on the 450D).'
)
),
h(
'section',
null,
h('h3', null, 'Camera'),
config
? h(
'form',
{ class: 'pure-form pure-form-aligned', onSubmit: e => e.preventDefault() },
h(Widget, { config, setValue })
)
: h('p', { class: 'notice' }, 'Reading camera configuration…')
)
)
)
);
}
}