Add a spoken countdown between frames
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Some checks failed
CI / build-and-deploy (push) Has been cancelled
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
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
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';
|
||||
|
||||
@@ -38,7 +39,12 @@ export const DEFAULT_PREFS = {
|
||||
liveViewBetweenFrames: true,
|
||||
keepAwake: true,
|
||||
bulbEnabled: false,
|
||||
bulbSeconds: 30
|
||||
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 */
|
||||
@@ -95,7 +101,9 @@ function Row({ label, hint, children }) {
|
||||
* folderName: string | undefined,
|
||||
* chooseFolder: () => void,
|
||||
* bulbSupport: ReturnType<typeof import('./config-utils.js').detectBulbSupport>,
|
||||
* locked: boolean
|
||||
* locked: boolean,
|
||||
* voices: SpeechSynthesisVoice[],
|
||||
* testVoice: () => void
|
||||
* }>
|
||||
*/
|
||||
export class SettingsDrawer extends Component {
|
||||
@@ -122,7 +130,9 @@ export class SettingsDrawer extends Component {
|
||||
folderName,
|
||||
chooseFolder,
|
||||
bulbSupport,
|
||||
locked
|
||||
locked,
|
||||
voices,
|
||||
testVoice
|
||||
} = props;
|
||||
|
||||
return h(
|
||||
@@ -255,6 +265,83 @@ export class SettingsDrawer extends Component {
|
||||
)
|
||||
),
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user