Files
web-dslr/examples/preact/index.html
Jon baaa0b5472
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add fullscreen preview, PWA support, and connect without a button
Fullscreen: a button on the preview pane fullscreens the live view, carrying
the countdown over as an overlay - fullscreen hides the entire control column,
so without it you're left staring at a picture with no idea when the next
frame lands.

Wake lock: held from app start until you switch it off, rather than only for
the duration of a sequence. You're usually mid-setup when the display would
otherwise sleep.

PWA: manifest, icons and a service worker, so it installs to a standalone
window and runs with no network. That matters more here than for most web
apps - the camera is on a USB cable, so this is fully functional in a field
with no signal.

The worker precaches the pinned unpkg dependencies at install rather than
leaving them to the runtime cache. On a first visit the worker isn't
controlling the page yet, so the app's own imports go straight to the network
and never reach the fetch handler; without the precache it looked cached but
died offline on its imports. Our own files are network-first so a redeploy
always wins, and the version-pinned CDN files are cache-first.

Connecting: no connect button. The app reaches for the camera on load, retries
every 1.5s, and listens for USB connect events, so switching the camera on
mid-wait attaches it with no click or reload. The exception is a browser that
has never been granted access to the device: Chrome will not open its WebUSB
chooser outside a user gesture, so that case still shows a one-off prompt.
After that the permission is remembered and getDevices() finds the camera with
no interaction.

tsconfig excludes sw.js, which runs in ServiceWorkerGlobalScope and reports
every worker global as undefined when checked against the DOM lib.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-02 09:53:24 +01:00

804 lines
18 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="manifest" href="./manifest.webmanifest" />
<meta name="theme-color" content="#14161a" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<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;
}
/* Fullscreen preview */
.viewer-button {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 6px 10px;
font-size: 16px;
line-height: 1.2;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-color: rgba(255, 255, 255, 0.25);
opacity: 0.55;
transition: opacity 0.15s;
}
#viewer:hover .viewer-button,
.viewer-button:focus-visible {
opacity: 1;
}
#viewer:fullscreen {
background: #000;
}
.viewer-status {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 14px;
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.55);
color: #fff;
}
.viewer-status-headline {
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.viewer-status-detail {
font-size: 13px;
opacity: 0.8;
}
#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);
}
.searching {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--muted);
}
.spinner {
width: 15px;
height: 15px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2.4s;
}
}
.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>'
/>
<script>
// Registered outside the module graph so a service worker failure can
// never stop the app itself from loading.
if ('serviceWorker' in navigator) {
addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js')
.catch(err => console.warn('Service worker registration failed:', err));
});
}
</script>
</head>
<body>
<div class="center">⌛ Loading…</div>
</body>
</html>