Files
web-dslr/examples/preact/index.html
Jon 539e3cfb34
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a spoken countdown between frames
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
2026-08-01 21:02:00 +01:00

709 lines
16 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Intervalometer — Canon over WebUSB</title>
<link
rel="stylesheet"
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
crossorigin="anonymous"
/>
<style>
:root {
color-scheme: dark light;
--bg: #14161a;
--panel: #1c1f26;
--panel-2: #242832;
--line: #333846;
--text: #e8eaee;
--muted: #9aa3b2;
--accent: #4f9cf9;
--accent-text: #06121f;
--danger: #e05252;
--warn: #e8b64c;
--ok: #4fbf7b;
--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;
}
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
overflow: hidden;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
}
a {
color: var(--accent);
}
/* ---------- header ---------- */
#app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#app-header .brand {
display: flex;
align-items: center;
gap: 12px;
}
#app-header .logo {
font-size: 24px;
}
#app-header h1 {
font-size: 17px;
}
#app-header small {
color: var(--muted);
font-size: 12px;
}
/* ---------- layout ---------- */
main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
}
#viewer {
position: relative;
min-height: 0;
background: #000;
display: flex;
}
#viewer .center-parent {
position: relative;
flex: 1;
display: flex;
min-height: 0;
}
#viewer canvas {
margin: auto;
max-width: 100%;
max-height: 100%;
}
.preview-overlay {
position: absolute;
inset: auto 0 0 0;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
text-align: center;
font-size: 13px;
}
#home {
min-height: 0;
overflow-y: auto;
padding: 14px;
border-left: 1px solid var(--line);
background: var(--bg);
display: flex;
flex-direction: column;
gap: 12px;
}
/* Nothing in this column may be squashed to fit - it scrolls instead.
(`overflow: hidden` on .readout would otherwise let flex shrink it to
zero height, since that zeroes the automatic minimum size.) */
#home > * {
flex: none;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
/* ---------- sequence status ---------- */
.sequence-status {
background: var(--panel);
border: 1px solid var(--line);
border-left: 4px solid var(--line);
border-radius: var(--radius);
padding: 14px;
}
.sequence-status.phase-waiting,
.sequence-status.phase-delay,
.sequence-status.phase-capturing {
border-left-color: var(--accent);
}
.sequence-status.phase-done {
border-left-color: var(--ok);
}
.sequence-status.phase-failed {
border-left-color: var(--danger);
}
.sequence-status.phase-stopped {
border-left-color: var(--warn);
}
.sequence-status .headline {
font-size: 22px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sequence-status .detail {
color: var(--muted);
font-size: 13px;
margin-top: 2px;
}
.warn-text {
color: var(--warn) !important;
}
.progress {
margin-top: 10px;
height: 6px;
border-radius: 3px;
background: var(--panel-2);
overflow: hidden;
}
.progress .bar {
height: 100%;
background: var(--accent);
transition: width 0.2s linear;
}
/* ---------- fields ---------- */
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.input-with-unit {
display: flex;
align-items: center;
gap: 6px;
}
.unit {
color: var(--muted);
font-size: 12px;
}
.inline-check {
display: flex;
align-items: center;
gap: 2px;
color: var(--muted);
font-size: 14px;
white-space: nowrap;
}
/* The config tree renders text widgets as a bare <input> with no type
attribute, so match on what it isn't - a typed selector list silently
misses those and leaves them with the browser's default light chrome. */
input:not([type='checkbox']):not([type='radio']):not([type='button']),
select {
min-width: 0;
width: 100%;
padding: 6px 8px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
font-variant-numeric: tabular-nums;
}
/* Dropdown lists are painted by the OS, not the page. */
option {
background: var(--panel-2);
color: var(--text);
}
input:disabled,
select:disabled {
opacity: 0.55;
}
/* Values the camera reports but won't let you change. */
input[readonly],
select[readonly] {
background: transparent;
border-color: transparent;
color: var(--muted);
}
.plan {
margin: 12px 0 0;
font-size: 13px;
color: var(--muted);
}
/* ---------- buttons ---------- */
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 14px;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
button.danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
font-weight: 600;
}
button.big {
padding: 12px 18px;
font-size: 16px;
}
button.icon-button {
background: none;
border: none;
font-size: 20px;
padding: 4px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
/* Labels shouldn't fold onto a second line to make room for a sibling -
the row wraps instead. */
.actions button {
white-space: nowrap;
}
.actions .big {
flex: 1;
}
.status-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.status-text {
min-width: 0;
}
.voice-toggle {
flex: none;
padding: 6px 10px;
font-size: 18px;
line-height: 1.2;
}
.voice-toggle.on {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ---------- readout & log ---------- */
.readout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* Cell borders rather than a gap over a coloured backdrop, so a partly
filled last row doesn't leave a stray block of grid line. */
.readout-item {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 8px 10px;
display: flex;
flex-direction: column;
min-width: 0;
}
.readout-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.readout-value {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log {
font-size: 12px;
border-top: 1px solid var(--line);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 8px;
color: var(--muted);
}
.log-entry.warn {
color: var(--warn);
}
.log-entry.error {
color: var(--danger);
}
.log-time {
font-variant-numeric: tabular-nums;
opacity: 0.7;
flex: none;
}
.notice {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: var(--panel-2);
color: var(--muted);
font-size: 12.5px;
}
.notice.warn {
color: var(--warn);
background: rgba(232, 182, 76, 0.1);
}
.tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--panel-2);
color: var(--muted);
border-radius: 4px;
padding: 2px 6px;
vertical-align: middle;
}
/* ---------- settings drawer ---------- */
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s, visibility 0.2s;
z-index: 5;
}
.scrim.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#settings {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(440px, 100%);
background: var(--panel);
border-left: 1px solid var(--line);
transform: translateX(100%);
/* `visibility` keeps the closed drawer out of the tab order and the
accessibility tree; it flips only once the slide-out finishes. */
visibility: hidden;
transition: transform 0.2s ease, visibility 0.2s;
display: flex;
flex-direction: column;
z-index: 6;
}
#settings.open {
transform: none;
visibility: visible;
}
#settings header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
#settings h2 {
font-size: 16px;
}
#settings h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 8px;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.drawer-body section {
margin-bottom: 22px;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.setting-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label small {
color: var(--muted);
font-size: 11.5px;
}
.setting-control {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-width: 140px;
justify-content: flex-end;
}
/* Needs the #settings prefix to outrank the generic input width above. */
#settings .setting-control input[type='number'] {
width: 80px;
}
/* camera config tree (rendered with purecss classes) */
#settings fieldset {
border: 1px solid var(--line);
border-radius: 8px;
margin: 0 0 10px;
padding: 6px 10px 10px;
}
#settings legend {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#settings .pure-control-group {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
#settings .pure-control-group label {
flex: 1;
font-size: 13px;
margin: 0;
}
/* Specific enough to beat purecss's own .pure-form control styling,
which is built for a light page. */
#settings .pure-control-group input:not([type='checkbox']),
#settings .pure-control-group select {
flex: none;
width: 48%;
/* purecss pins selects to 2.25em, which clips descenders once our own
padding is added. */
height: auto;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
box-shadow: none;
}
#settings .pure-control-group input[readonly],
#settings .pure-control-group select[readonly] {
background: transparent;
border-color: transparent;
color: var(--muted);
}
#settings .pure-control-group input[type='checkbox'] {
flex: none;
width: auto;
}
/* ---------- misc ---------- */
.center {
margin: auto;
text-align: center;
padding: 20px;
}
.muted {
color: var(--muted);
}
.fine-print {
max-width: 46ch;
margin: 16px auto 0;
font-size: 12.5px;
color: var(--muted);
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 38vh) minmax(0, 1fr);
overflow: hidden;
}
#home {
border-left: none;
border-top: 1px solid var(--line);
}
}
</style>
<script type="importmap">
{
"imports": {
"web-gphoto2": "https://unpkg.com/web-gphoto2@0.4.1/build/camera.js",
"preact": "https://unpkg.com/preact@10.6.4/dist/preact.module.js",
"preact/debug": "https://unpkg.com/preact@10.6.4/debug/dist/debug.module.js",
"preact/devtools": "https://unpkg.com/preact@10.6.4/devtools/dist/devtools.module.js",
"stats.js": "https://unpkg.com/stats.js@0.17.0/src/Stats.js"
}
}
</script>
<script type="module">
try {
if (!('usb' in navigator)) {
throw new Error('WebUSB is unsupported');
}
await import('./index.js');
} catch (e) {
console.error(e);
await import('./index-fallback.js');
}
</script>
<link
rel="icon"
href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 110 100"><text y=".9em" font-size="90">⏱</text></svg>'
/>
</head>
<body>
<div class="center">⌛ Loading…</div>
</body>
</html>