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