Add a theme toggle, and fix the voice picker showing no voices
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Some checks failed
CI / build-and-deploy (push) Has been cancelled
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
This commit is contained in:
@@ -13,9 +13,31 @@
|
||||
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script>
|
||||
// Resolve the theme before the first paint, or the page flashes dark on
|
||||
// its way to light. Kept inline and dependency-free for that reason.
|
||||
(() => {
|
||||
let choice = 'system';
|
||||
try {
|
||||
choice =
|
||||
JSON.parse(localStorage.getItem('web-dslr.prefs') || '{}').theme ||
|
||||
'system';
|
||||
} catch {}
|
||||
document.documentElement.dataset.theme =
|
||||
choice === 'light' || choice === 'dark'
|
||||
? choice
|
||||
: matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark';
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
/* Dark is the base. The script above always stamps an explicit
|
||||
data-theme, so there's no prefers-color-scheme query here to fight
|
||||
with an override the user has chosen. */
|
||||
:root,
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #14161a;
|
||||
--panel: #1c1f26;
|
||||
--panel-2: #242832;
|
||||
@@ -30,17 +52,16 @@
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f2f4f7;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f7f8fa;
|
||||
--line: #d9dee6;
|
||||
--text: #1a1d23;
|
||||
--muted: #5c6675;
|
||||
--accent: #1f6fd0;
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--bg: #f2f4f7;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f7f8fa;
|
||||
--line: #d9dee6;
|
||||
--text: #1a1d23;
|
||||
--muted: #5c6675;
|
||||
--accent: #1f6fd0;
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -91,6 +112,12 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#app-header .header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#app-header .logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ if (isDebug) {
|
||||
/** @param {number} ms */
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const systemPrefersLight = () => matchMedia('(prefers-color-scheme: light)');
|
||||
|
||||
/**
|
||||
* Turn the stored preference into the theme actually in force.
|
||||
* @param {'system' | 'light' | 'dark'} choice
|
||||
*/
|
||||
const resolveTheme = choice =>
|
||||
choice === 'light' || choice === 'dark'
|
||||
? choice
|
||||
: systemPrefersLight().matches
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
/** @extends Component<{}, AppState> */
|
||||
class App extends Component {
|
||||
/** @type {Camera | undefined} */
|
||||
@@ -71,12 +84,20 @@ class App extends Component {
|
||||
componentDidMount() {
|
||||
this.saver.mode = this.state.prefs.saveMode;
|
||||
this.syncNarrator(this.state.prefs);
|
||||
// The voice list arrives after construction; re-render when it does.
|
||||
this.narrator.onVoicesChanged = () => this.forceUpdate();
|
||||
// Held for as long as the app is open, not just while a sequence runs -
|
||||
// you're usually mid-setup when the display would otherwise sleep.
|
||||
if (this.state.prefs.keepAwake) this.wakeLock.acquire();
|
||||
|
||||
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
|
||||
|
||||
this.applyTheme(this.state.prefs.theme);
|
||||
// While the choice is 'system', follow the OS if it changes underneath us.
|
||||
systemPrefersLight().addEventListener('change', () => {
|
||||
if (this.state.prefs.theme === 'system') this.applyTheme('system');
|
||||
});
|
||||
|
||||
addEventListener('error', ({ message }) =>
|
||||
this.log(`Uncaught error: ${message}`, 'error')
|
||||
);
|
||||
@@ -117,13 +138,34 @@ class App extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param {'system' | 'light' | 'dark'} choice */
|
||||
applyTheme(choice) {
|
||||
let theme = resolveTheme(choice);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
// Keeps the PWA title bar and mobile browser chrome in step.
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', theme === 'light' ? '#ffffff' : '#14161a');
|
||||
this.setState({ theme });
|
||||
}
|
||||
|
||||
toggleTheme = () => {
|
||||
let next = /** @type {'light' | 'dark'} */ (
|
||||
resolveTheme(this.state.prefs.theme) === 'dark' ? 'light' : 'dark'
|
||||
);
|
||||
this.applyTheme(next);
|
||||
this.setPref({ theme: next });
|
||||
};
|
||||
|
||||
/** @param {import('./settings.js').Prefs} prefs */
|
||||
syncNarrator(prefs) {
|
||||
Object.assign(this.narrator, {
|
||||
enabled: prefs.voice,
|
||||
countFrom: prefs.voiceCountFrom,
|
||||
voiceURI: prefs.voiceURI,
|
||||
announceFrames: prefs.voiceAnnounceFrames
|
||||
announceFrames: prefs.voiceAnnounceFrames,
|
||||
rate: prefs.voiceRate,
|
||||
pitch: prefs.voicePitch
|
||||
});
|
||||
}
|
||||
|
||||
@@ -412,14 +454,31 @@ class App extends Component {
|
||||
)
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'icon-button',
|
||||
onclick: this.toggleSettings,
|
||||
title: 'Settings'
|
||||
},
|
||||
'⚙'
|
||||
'div',
|
||||
{ class: 'header-actions' },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'icon-button',
|
||||
onclick: this.toggleTheme,
|
||||
title:
|
||||
this.state.theme === 'dark'
|
||||
? 'Switch to light mode'
|
||||
: 'Switch to dark mode'
|
||||
},
|
||||
this.state.theme === 'dark' ? '☀' : '☾'
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'icon-button settings-button',
|
||||
onclick: this.toggleSettings,
|
||||
title: 'Settings'
|
||||
},
|
||||
'⚙'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,15 @@ export const DEFAULT_PREFS = {
|
||||
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
|
||||
voiceAnnounceFrames: false,
|
||||
voiceRate: 1.1,
|
||||
voicePitch: 1
|
||||
};
|
||||
|
||||
/** @typedef {typeof DEFAULT_PREFS} Prefs */
|
||||
@@ -95,6 +99,39 @@ 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
|
||||
*/
|
||||
@@ -398,7 +435,12 @@ export class SettingsDrawer extends Component {
|
||||
),
|
||||
h(
|
||||
Row,
|
||||
{ label: 'Voice' },
|
||||
{
|
||||
label: 'Voice',
|
||||
hint: voices.length
|
||||
? `${voices.length} available. Network voices won't work offline.`
|
||||
: 'Loading the system voice list…'
|
||||
},
|
||||
h(
|
||||
'select',
|
||||
{
|
||||
@@ -406,11 +448,56 @@ export class SettingsDrawer extends Component {
|
||||
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})`)
|
||||
// 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' },
|
||||
|
||||
2
examples/preact/types.d.ts
vendored
2
examples/preact/types.d.ts
vendored
@@ -19,4 +19,6 @@ type AppState = {
|
||||
singleShotStatus: 'idle' | 'busy';
|
||||
/** Whether the preview pane is currently filling the screen. */
|
||||
fullscreen?: boolean;
|
||||
/** The theme actually in force, once 'system' has been resolved. */
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
@@ -36,6 +36,15 @@ export class Narrator {
|
||||
announceFrames = false;
|
||||
/** Empty means the browser's default voice. */
|
||||
voiceURI = '';
|
||||
rate = 1.1;
|
||||
pitch = 1;
|
||||
/**
|
||||
* Called when the voice list changes, so the UI can re-render. Chrome
|
||||
* reports zero voices synchronously and only fills the list later, so
|
||||
* without this the picker is stuck on whatever existed at construction.
|
||||
* @type {(() => void) | undefined}
|
||||
*/
|
||||
onVoicesChanged;
|
||||
|
||||
/** @type {number | null} */
|
||||
#lastSecond = null;
|
||||
@@ -49,11 +58,27 @@ export class Narrator {
|
||||
if (!supportsSpeech) return;
|
||||
this.#loadVoices();
|
||||
// Voices arrive asynchronously, and on some platforms only after this fires.
|
||||
speechSynthesis.onvoiceschanged = () => this.#loadVoices();
|
||||
speechSynthesis.onvoiceschanged = () => {
|
||||
this.#loadVoices();
|
||||
this.onVoicesChanged?.();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome hands back ~180 voices in no useful order. Sort them so the ones
|
||||
* you'd actually pick are near the top: your own language first, then
|
||||
* offline voices ahead of network ones - the latter are useless in the field.
|
||||
*/
|
||||
#loadVoices() {
|
||||
this.#voices = speechSynthesis.getVoices();
|
||||
let primary = (navigator.language || 'en').split('-')[0].toLowerCase();
|
||||
let rank = v => (v.lang.toLowerCase().startsWith(primary) ? 0 : 1);
|
||||
this.#voices = speechSynthesis.getVoices().slice().sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
a.lang.localeCompare(b.lang) ||
|
||||
Number(b.localService) - Number(a.localService) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
get voices() {
|
||||
@@ -72,8 +97,10 @@ export class Narrator {
|
||||
let utterance = new SpeechSynthesisUtterance(text);
|
||||
let voice = this.#voices.find(v => v.voiceURI === this.voiceURI);
|
||||
if (voice) utterance.voice = voice;
|
||||
// Slightly quick, so a spoken "three" lands nearer the second it names.
|
||||
utterance.rate = 1.1;
|
||||
// Default is slightly quick, so a spoken "three" lands nearer the second
|
||||
// it names.
|
||||
utterance.rate = this.rate;
|
||||
utterance.pitch = this.pitch;
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user