Files
web-dslr/examples/preact/settings.js
Jon 539e3cfb34
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a spoken countdown between frames
A 🔊 toggle on the home screen counts you into each frame - "five, four,
three, two, one" - plus start and finish announcements. Useful when you're in
front of the camera rather than at the laptop. Uses the Web Speech API, so
it's local and needs no configuration.

The narrator runs off the intervalometer's state updates rather than a timer
of its own, so it can't drift away from what the sequence is doing; each
second is spoken at most once even though state arrives ~5x/sec.

The count is capped at one second under the interval, so a 3s interval says
"two, one" instead of talking over the previous frame, and a 1s interval stays
silent. A start delay isn't clamped, since that gap is whatever you set.

Countdown length, voice choice and frame-number announcements live in the
settings drawer; only the toggle is on the home screen.

Also:
- The toggle sits in the status card rather than the button row: three buttons
  don't fit a 400px column, and it wrapped onto its own flex line where it
  stretched to the wrong height.
- tsconfig excludes dist/, which build-dist.sh fills with copies of these same
  files and which otherwise gets type-checked twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-01 21:02:00 +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: '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…')
)
)
)
);
}
}