Files
web-dslr/examples/preact/settings.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

581 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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,
/** 'system' follows the OS until the header toggle is used. */
theme: /** @type {'system' | 'light' | 'dark'} */ ('system'),
// Toggled from the home screen; the rest live in this drawer.
voice: false,
voiceCountFrom: 5,
voiceURI: '',
voiceAnnounceFrames: false,
voiceRate: 1.1,
voicePitch: 1
};
/** @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);
}
}
/**
* The camera's top-level config sections, each of which becomes a tab.
* Empty ones are dropped so we don't offer a tab onto nothing.
*
* @param {import('web-gphoto2').Config | undefined} config
* @returns {(import('web-gphoto2').Config & { type: 'section', children: any })[]}
*/
function cameraSections(config) {
if (!config || config.type !== 'window') return [];
return /** @type {any} */ (Object.values(config.children).filter(
child =>
(child.type === 'section' || child.type === 'window') &&
Object.keys(child.children).length > 0
));
}
/**
* Section labels are all "Camera Actions", "Camera Settings", … - the prefix
* is dead weight in a tab that's already inside the camera's settings.
* @param {string} label
*/
function tabLabel(label) {
return label.replace(/^camera\s+/i, '') || label;
}
/**
* Bucket voices into <optgroup>s by language, keeping the order the narrator
* sorted them into (your own language first, offline voices before network).
*
* @param {SpeechSynthesisVoice[]} voices
*/
function groupVoices(voices) {
/** @type {{ lang: string, voices: SpeechSynthesisVoice[] }[]} */
let groups = [];
let byLang = new Map();
for (let voice of voices) {
let group = byLang.get(voice.lang);
if (!group) {
group = { lang: voice.lang, voices: [] };
byLang.set(voice.lang, group);
groups.push(group);
}
group.voices.push(voice);
}
return groups;
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
* @param {number} fallback Used when the field is left empty (value is NaN).
*/
function clamp(value, min, max, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
}
/**
* @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 {
/** Selected tab: 'app', or the gphoto2 name of a config section. */
state = { tab: 'app' };
#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;
// One tab per top-level section the camera reports, so the config tree
// stops being one enormous scroll.
let sections = cameraSections(config);
// The chosen section can vanish if the camera is swapped or reports
// differently after a change; fall back rather than render nothing.
let tab =
this.state.tab !== 'app' && !sections.some(s => s.name === this.state.tab)
? 'app'
: this.state.tab;
let selected = sections.find(s => s.name === tab);
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: 'tab-bar' },
h(
'button',
{
type: 'button',
class: `tab ${tab === 'app' ? 'active' : ''}`,
onclick: () => this.setState({ tab: 'app' })
},
'App'
),
sections.map(section =>
h(
'button',
{
key: section.name,
type: 'button',
class: `tab ${tab === section.name ? 'active' : ''}`,
title: section.label,
onclick: () => this.setState({ tab: section.name })
},
tabLabel(section.label)
)
)
),
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,
selected
? h(
'form',
{
class: 'pure-form pure-form-aligned',
onSubmit: e => e.preventDefault()
},
// The section's own children, not the section node itself -
// the tab already names it, so a fieldset around it is noise.
Object.values(selected.children).map(child =>
h(Widget, { key: child.name, config: child, setValue })
)
)
: undefined,
tab !== 'app'
? undefined
: h(
Fragment,
null,
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',
hint: voices.length
? `${voices.length} available. Network voices won't work offline.`
: 'Loading the system voice list…'
},
h(
'select',
{
value: prefs.voiceURI,
onChange: e => setPref({ voiceURI: e.currentTarget.value })
},
h('option', { value: '' }, 'Browser default'),
// Grouped by language: a flat list of ~180 is unusable.
groupVoices(voices).map(group =>
h(
'optgroup',
{ key: group.lang, label: group.lang },
group.voices.map(v =>
h(
'option',
{ key: v.voiceURI, value: v.voiceURI },
v.localService ? v.name : `${v.name} (network)`
)
)
)
)
)
),
h(
Row,
{
label: 'Speed',
hint: 'Quicker means a spoken number lands nearer the second it names.'
},
h('input', {
type: 'number',
min: '0.5',
max: '2',
step: '0.1',
value: prefs.voiceRate,
onChange: e =>
setPref({
voiceRate: clamp(e.currentTarget.valueAsNumber, 0.5, 2, 1.1)
})
}),
h('span', { class: 'unit' }, '×')
),
h(
Row,
{ label: 'Pitch' },
h('input', {
type: 'number',
min: '0',
max: '2',
step: '0.1',
value: prefs.voicePitch,
onChange: e =>
setPref({
voicePitch: clamp(e.currentTarget.valueAsNumber, 0, 2, 1)
})
})
),
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 instead (up to 30s on the 450D).'
)
),
!config
? h('p', { class: 'notice' }, 'Reading camera configuration…')
: undefined
)
)
)
);
}
}