Some checks failed
CI / build-and-deploy (push) Has been cancelled
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
804 lines
18 KiB
HTML
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>
|