/* * 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 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) => void, * config: import('web-gphoto2').Config | undefined, * setValue: (name: string, value: any) => Promise, * folderName: string | undefined, * chooseFolder: () => void, * bulbSupport: ReturnType, * 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 ) ) ) ); } }