Add fullscreen preview, PWA support, and connect without a button
Some checks failed
CI / build-and-deploy (push) Has been cancelled
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
This commit is contained in:
@@ -58,7 +58,9 @@ npm install
|
|||||||
npx serve . # serve.json sets the COOP/COEP headers the WASM module needs
|
npx serve . # serve.json sets the COOP/COEP headers the WASM module needs
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the printed URL in Chrome, plug the camera in over USB, switch it on and pick it from the device picker.
|
Open the printed URL in Chrome and plug the camera in. It connects on its own — there's no connect button. The one exception is the very first visit, which shows a "Choose camera" prompt: Chrome will not open its WebUSB device chooser without a click. After that the permission is remembered, and `navigator.usb.getDevices()` finds the camera with no interaction, so later visits go straight in. If the camera is off or asleep the page sits on "Looking for your camera…" and keeps retrying, connecting the moment it wakes up.
|
||||||
|
|
||||||
|
It's also an installable PWA: install it from Chrome's address bar to get a standalone window, and it works with no network at all — the app shell and the pinned unpkg dependencies (including the ~2 MB WASM) are precached by a service worker. That matters here because the camera is on a USB cable, so the app is perfectly useful in a field with no signal.
|
||||||
|
|
||||||
**Home screen** — only the things you change between runs:
|
**Home screen** — only the things you change between runs:
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ Open the printed URL in Chrome, plug the camera in over USB, switch it on and pi
|
|||||||
- Start/stop, plus a single-shot button for framing
|
- Start/stop, plus a single-shot button for framing
|
||||||
- Live countdown to the next frame, progress, estimated finish time, and a log of the last few events
|
- Live countdown to the next frame, progress, estimated finish time, and a log of the last few events
|
||||||
- A 🔊 toggle that speaks the countdown into each frame ("five, four, three, two, one") along with start and finish, for when you're in front of the camera rather than at the laptop
|
- A 🔊 toggle that speaks the countdown into each frame ("five, four, three, two, one") along with start and finish, for when you're in front of the camera rather than at the laptop
|
||||||
|
- A ⛶ button on the preview for a fullscreen live view, with the countdown carried over as an overlay so you don't lose it
|
||||||
- A read-only strip showing shutter / aperture / ISO / format / battery / shots remaining, so you can sanity-check exposure against the interval without opening anything
|
- A read-only strip showing shutter / aperture / ISO / format / battery / shots remaining, so you can sanity-check exposure against the interval without opening anything
|
||||||
- Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set
|
- Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ set -eu
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
MODULES="index.js index-fallback.js home.js settings.js intervalometer.js
|
MODULES="index.js index-fallback.js home.js settings.js intervalometer.js
|
||||||
storage.js config-utils.js preview.js voice.js widget.js"
|
storage.js config-utils.js preview.js voice.js widget.js sw.js"
|
||||||
|
ASSETS="index.html _headers manifest.webmanifest
|
||||||
|
icon-192.png icon-512.png icon-maskable-512.png"
|
||||||
# Collapse the line break to single spaces so the membership test below works.
|
# Collapse the line break to single spaces so the membership test below works.
|
||||||
# shellcheck disable=SC2116,SC2086
|
# shellcheck disable=SC2116,SC2086
|
||||||
MODULES=$(echo $MODULES)
|
MODULES=$(echo $MODULES)
|
||||||
|
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp index.html _headers dist/
|
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
cp $MODULES dist/
|
cp $ASSETS $MODULES dist/
|
||||||
|
|
||||||
missing=""
|
missing=""
|
||||||
for f in *.js; do
|
for f in *.js; do
|
||||||
|
|||||||
@@ -98,45 +98,8 @@ function sequenceWarnings(prefs, config, hasFolder) {
|
|||||||
*/
|
*/
|
||||||
function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) {
|
function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) {
|
||||||
let { phase, taken, total } = state;
|
let { phase, taken, total } = state;
|
||||||
let headline;
|
let { headline, detail } = sequenceSummary(state, prefs);
|
||||||
let detail;
|
|
||||||
|
|
||||||
switch (phase) {
|
|
||||||
case 'delay':
|
|
||||||
headline = `Starting in ${formatDuration(secondsUntilNext(state))}`;
|
|
||||||
detail = 'Waiting out the start delay.';
|
|
||||||
break;
|
|
||||||
case 'waiting':
|
|
||||||
headline = `Next frame in ${formatDuration(secondsUntilNext(state))}`;
|
|
||||||
detail = `${taken} of ${total || '∞'} captured`;
|
|
||||||
break;
|
|
||||||
case 'capturing':
|
|
||||||
headline = prefs.bulbEnabled
|
|
||||||
? `⏱ Bulb exposure — ${prefs.bulbSeconds}s`
|
|
||||||
: '📸 Capturing…';
|
|
||||||
detail = `${taken} of ${total || '∞'} captured`;
|
|
||||||
break;
|
|
||||||
case 'done':
|
|
||||||
headline = '✅ Sequence complete';
|
|
||||||
detail = `${taken} frame${taken === 1 ? '' : 's'} in ${formatDuration(
|
|
||||||
(state.finishedAt - state.startedAt) / 1000
|
|
||||||
)}`;
|
|
||||||
break;
|
|
||||||
case 'stopped':
|
|
||||||
headline = '⏹ Stopped';
|
|
||||||
detail = `${taken} frame${taken === 1 ? '' : 's'} captured`;
|
|
||||||
break;
|
|
||||||
case 'failed':
|
|
||||||
headline = '❌ Sequence failed';
|
|
||||||
detail = `${taken} captured, ${state.errors} failed`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
headline = 'Ready';
|
|
||||||
detail = 'Set an interval and press Start.';
|
|
||||||
}
|
|
||||||
|
|
||||||
let progress = total ? Math.min(1, taken / total) : 0;
|
let progress = total ? Math.min(1, taken / total) : 0;
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
{ class: `sequence-status phase-${phase}` },
|
{ class: `sequence-status phase-${phase}` },
|
||||||
@@ -178,6 +141,56 @@ function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-line description of where the sequence is up to. Shared by the status
|
||||||
|
* card and the fullscreen preview overlay.
|
||||||
|
*
|
||||||
|
* @param {SequenceState} state
|
||||||
|
* @param {Prefs} prefs
|
||||||
|
* @returns {{ headline: string, detail: string }}
|
||||||
|
*/
|
||||||
|
export function sequenceSummary(state, prefs) {
|
||||||
|
let { phase, taken, total } = state;
|
||||||
|
let headline;
|
||||||
|
let detail;
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 'delay':
|
||||||
|
headline = `Starting in ${formatDuration(secondsUntilNext(state))}`;
|
||||||
|
detail = 'Waiting out the start delay.';
|
||||||
|
break;
|
||||||
|
case 'waiting':
|
||||||
|
headline = `Next frame in ${formatDuration(secondsUntilNext(state))}`;
|
||||||
|
detail = `${taken} of ${total || '∞'} captured`;
|
||||||
|
break;
|
||||||
|
case 'capturing':
|
||||||
|
headline = prefs.bulbEnabled
|
||||||
|
? `⏱ Bulb exposure — ${prefs.bulbSeconds}s`
|
||||||
|
: '📸 Capturing…';
|
||||||
|
detail = `${taken} of ${total || '∞'} captured`;
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
headline = '✅ Sequence complete';
|
||||||
|
detail = `${taken} frame${taken === 1 ? '' : 's'} in ${formatDuration(
|
||||||
|
(state.finishedAt - state.startedAt) / 1000
|
||||||
|
)}`;
|
||||||
|
break;
|
||||||
|
case 'stopped':
|
||||||
|
headline = '⏹ Stopped';
|
||||||
|
detail = `${taken} frame${taken === 1 ? '' : 's'} captured`;
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
headline = '❌ Sequence failed';
|
||||||
|
detail = `${taken} captured, ${state.errors} failed`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
headline = 'Ready';
|
||||||
|
detail = 'Set an interval and press Start.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headline, detail };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole home-screen control column: interval timing, start/stop, progress,
|
* The whole home-screen control column: interval timing, start/stop, progress,
|
||||||
* and a running log. Camera settings deliberately live in the drawer instead.
|
* and a running log. Camera settings deliberately live in the drawer instead.
|
||||||
|
|||||||
BIN
examples/preact/icon-192.png
Normal file
BIN
examples/preact/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
examples/preact/icon-512.png
Normal file
BIN
examples/preact/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
examples/preact/icon-maskable-512.png
Normal file
BIN
examples/preact/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Intervalometer — Canon over WebUSB</title>
|
<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
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
|
href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
|
||||||
@@ -139,6 +143,57 @@
|
|||||||
font-size: 13px;
|
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 {
|
#home {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -655,6 +710,35 @@
|
|||||||
color: var(--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 {
|
.fine-print {
|
||||||
max-width: 46ch;
|
max-width: 46ch;
|
||||||
margin: 16px auto 0;
|
margin: 16px auto 0;
|
||||||
@@ -701,6 +785,17 @@
|
|||||||
rel="icon"
|
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>'
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="center">⌛ Loading…</div>
|
<div class="center">⌛ Loading…</div>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { h, hydrate, Component, Fragment } from 'preact';
|
import { h, hydrate, Component, Fragment, createRef } from 'preact';
|
||||||
import { Camera, rethrowIfCritical } from 'web-gphoto2';
|
import { Camera, rethrowIfCritical } from 'web-gphoto2';
|
||||||
import { Preview } from './preview.js';
|
import { Preview } from './preview.js';
|
||||||
import { Home } from './home.js';
|
import { Home, sequenceSummary } from './home.js';
|
||||||
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
|
import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
|
||||||
import { Intervalometer, formatDuration } from './intervalometer.js';
|
import { Intervalometer, formatDuration } from './intervalometer.js';
|
||||||
import { FrameSaver, WakeLock } from './storage.js';
|
import { FrameSaver, WakeLock } from './storage.js';
|
||||||
@@ -66,10 +66,16 @@ class App extends Component {
|
|||||||
#logId = 0;
|
#logId = 0;
|
||||||
#configWatchToken = 0;
|
#configWatchToken = 0;
|
||||||
#wasRunning = false;
|
#wasRunning = false;
|
||||||
|
viewerRef = createRef();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.saver.mode = this.state.prefs.saveMode;
|
this.saver.mode = this.state.prefs.saveMode;
|
||||||
this.syncNarrator(this.state.prefs);
|
this.syncNarrator(this.state.prefs);
|
||||||
|
// 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);
|
||||||
|
|
||||||
addEventListener('error', ({ message }) =>
|
addEventListener('error', ({ message }) =>
|
||||||
this.log(`Uncaught error: ${message}`, 'error')
|
this.log(`Uncaught error: ${message}`, 'error')
|
||||||
@@ -92,9 +98,9 @@ class App extends Component {
|
|||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try to connect to camera at startup.
|
// No connect button: just keep reaching for the camera from the moment
|
||||||
// If none is found among saved connections, it will fallback to a picker.
|
// the page loads.
|
||||||
this.tryToConnectToCamera();
|
this.autoConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,7 +134,8 @@ class App extends Component {
|
|||||||
savePrefs(next);
|
savePrefs(next);
|
||||||
this.saver.mode = next.saveMode;
|
this.saver.mode = next.saveMode;
|
||||||
this.syncNarrator(next);
|
this.syncNarrator(next);
|
||||||
if (!next.keepAwake) this.wakeLock.release();
|
if (next.keepAwake) this.wakeLock.acquire();
|
||||||
|
else this.wakeLock.release();
|
||||||
return { prefs: next };
|
return { prefs: next };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -152,23 +159,64 @@ class App extends Component {
|
|||||||
this.narrator.enabled = wasEnabled;
|
this.narrator.enabled = wasEnabled;
|
||||||
};
|
};
|
||||||
|
|
||||||
selectDevice = async () => {
|
grantAccess = async () => {
|
||||||
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await Camera.showPicker();
|
await Camera.showPicker();
|
||||||
this.setState({ view: 'status', message: '⌛ Connecting…' });
|
} catch (err) {
|
||||||
await this.tryToConnectToCamera();
|
// Dismissing the chooser is not an error worth shouting about.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.connectOnce();
|
||||||
};
|
};
|
||||||
|
|
||||||
async tryToConnectToCamera() {
|
/**
|
||||||
|
* Keep trying to attach to a camera the browser has already been given
|
||||||
|
* permission for, so landing on the page is all it takes.
|
||||||
|
*
|
||||||
|
* WebUSB will only show its device chooser from a user gesture, so the one
|
||||||
|
* case that genuinely can't be automated is the very first time - after that
|
||||||
|
* the permission is remembered and `getDevices()` sees the camera with no
|
||||||
|
* interaction at all.
|
||||||
|
*/
|
||||||
|
async autoConnect() {
|
||||||
|
// Reconnect the moment a known camera is plugged in or switched on.
|
||||||
|
// @ts-ignore
|
||||||
|
navigator.usb?.addEventListener?.('connect', () => this.connectOnce());
|
||||||
|
|
||||||
|
while (!this.camera) {
|
||||||
|
let permitted = await this.permittedDevices();
|
||||||
|
if (permitted.length) {
|
||||||
|
this.setState({ view: 'searching' });
|
||||||
|
if (await this.connectOnce()) return;
|
||||||
|
} else {
|
||||||
|
this.setState({ view: 'permission' });
|
||||||
|
}
|
||||||
|
await wait(1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async permittedDevices() {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
return await navigator.usb.getDevices();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<boolean>} whether we're now connected. */
|
||||||
|
async connectOnce() {
|
||||||
|
if (this.camera) return true;
|
||||||
/** @type {Camera} */
|
/** @type {Camera} */
|
||||||
let camera;
|
let camera;
|
||||||
try {
|
try {
|
||||||
camera = new Camera();
|
camera = new Camera();
|
||||||
await camera.connect();
|
await camera.connect();
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
console.warn(e);
|
// Expected while the camera is off, asleep, or claimed by another app.
|
||||||
this.setState({ view: 'picker' });
|
console.debug('Camera not ready yet:', err);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
let supportedOps = await camera.getSupportedOps();
|
let supportedOps = await camera.getSupportedOps();
|
||||||
@@ -179,6 +227,7 @@ class App extends Component {
|
|||||||
configValue(this.state.config, 'model') ||
|
configValue(this.state.config, 'model') ||
|
||||||
'camera'}.`
|
'camera'}.`
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshConfig() {
|
async refreshConfig() {
|
||||||
@@ -295,12 +344,28 @@ class App extends Component {
|
|||||||
this.narrator.update(seq);
|
this.narrator.update(seq);
|
||||||
this.setState({ seq });
|
this.setState({ seq });
|
||||||
if (this.#wasRunning && !running) {
|
if (this.#wasRunning && !running) {
|
||||||
this.wakeLock.release();
|
// The wake lock stays - it's app-wide now, not sequence-scoped.
|
||||||
this.refreshConfig();
|
this.refreshConfig();
|
||||||
}
|
}
|
||||||
this.#wasRunning = running;
|
this.#wasRunning = running;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleFullscreenChange = () => {
|
||||||
|
this.setState({ fullscreen: !!document.fullscreenElement });
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleFullscreen = async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
await this.viewerRef.current?.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.log(`Could not toggle fullscreen: ${err}`, 'warn');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
chooseFolder = async () => {
|
chooseFolder = async () => {
|
||||||
try {
|
try {
|
||||||
let name = await this.saver.chooseFolder();
|
let name = await this.saver.chooseFolder();
|
||||||
@@ -403,21 +468,38 @@ class App extends Component {
|
|||||||
|
|
||||||
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
|
render(/** @type {App['props']} */ props, /** @type {App['state']} */ state) {
|
||||||
switch (state.view) {
|
switch (state.view) {
|
||||||
case 'picker':
|
case 'searching':
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'center' },
|
{ class: 'center' },
|
||||||
h('h1', null, '⏱ Intervalometer'),
|
h('h1', null, '⏱ Intervalometer'),
|
||||||
h('p', null, 'Connect your Canon 450D over USB and switch it on.'),
|
h('p', { class: 'searching' }, h('span', { class: 'spinner' }), 'Looking for your camera…'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'fine-print' },
|
||||||
|
'Switch the camera on and set the mode dial off Auto. It will connect on its own — no need to reload. On macOS, quit Photos and Image Capture if they grabbed it first.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'permission':
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ class: 'center' },
|
||||||
|
h('h1', null, '⏱ Intervalometer'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
null,
|
||||||
|
'One-off setup: the browser needs your permission to talk to the camera.'
|
||||||
|
),
|
||||||
h(
|
h(
|
||||||
'button',
|
'button',
|
||||||
{ type: 'button', class: 'primary big', onclick: this.selectDevice },
|
{ type: 'button', class: 'primary big', onclick: this.grantAccess },
|
||||||
'🔍 Select camera'
|
'🔍 Choose camera'
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'fine-print' },
|
{ class: 'fine-print' },
|
||||||
'Requires Chrome with WebUSB. On Linux you may need a udev rule, and on macOS you may need to quit Photos/Image Capture first. Built on ',
|
'Only needed once — after this it connects by itself whenever you open the page. Chrome will not open its device chooser without a click, which is the one part of this that cannot be automatic. Built on ',
|
||||||
h(
|
h(
|
||||||
'a',
|
'a',
|
||||||
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
|
{ href: 'https://github.com/GoogleChromeLabs/web-gphoto2' },
|
||||||
@@ -440,7 +522,11 @@ class App extends Component {
|
|||||||
null,
|
null,
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ id: 'viewer' },
|
{
|
||||||
|
id: 'viewer',
|
||||||
|
ref: this.viewerRef,
|
||||||
|
class: state.fullscreen ? 'is-fullscreen' : ''
|
||||||
|
},
|
||||||
showPreview
|
showPreview
|
||||||
? h(Preview, {
|
? h(Preview, {
|
||||||
getPreview: () => this.camera.capturePreviewAsBlob(),
|
getPreview: () => this.camera.capturePreviewAsBlob(),
|
||||||
@@ -461,7 +547,37 @@ class App extends Component {
|
|||||||
previewSupported
|
previewSupported
|
||||||
? 'Live view is turned off in Settings.'
|
? 'Live view is turned off in Settings.'
|
||||||
: 'This camera does not support live preview.'
|
: 'This camera does not support live preview.'
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: 'viewer-button',
|
||||||
|
onclick: this.toggleFullscreen,
|
||||||
|
title: state.fullscreen
|
||||||
|
? 'Exit fullscreen (Esc)'
|
||||||
|
: 'Fullscreen preview'
|
||||||
|
},
|
||||||
|
state.fullscreen ? '✕' : '⛶'
|
||||||
|
),
|
||||||
|
// Fullscreen hides the whole control column, so carry the
|
||||||
|
// countdown across rather than leaving you staring at a picture.
|
||||||
|
state.fullscreen
|
||||||
|
? h(
|
||||||
|
'div',
|
||||||
|
{ class: 'viewer-status' },
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'viewer-status-headline' },
|
||||||
|
sequenceSummary(state.seq, state.prefs).headline
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'viewer-status-detail' },
|
||||||
|
sequenceSummary(state.seq, state.prefs).detail
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
),
|
),
|
||||||
h(Home, {
|
h(Home, {
|
||||||
prefs: state.prefs,
|
prefs: state.prefs,
|
||||||
|
|||||||
22
examples/preact/manifest.webmanifest
Normal file
22
examples/preact/manifest.webmanifest
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "DSLR Intervalometer",
|
||||||
|
"short_name": "Intervalometer",
|
||||||
|
"description": "Shoot timelapses on a USB-connected DSLR from the browser.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#14161a",
|
||||||
|
"theme_color": "#14161a",
|
||||||
|
"categories": ["photo", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "./icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "./icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{
|
||||||
|
"src": "./icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -254,7 +254,7 @@ export class SettingsDrawer extends Component {
|
|||||||
Row,
|
Row,
|
||||||
{
|
{
|
||||||
label: 'Keep screen awake',
|
label: 'Keep screen awake',
|
||||||
hint: 'Background tabs get their timers throttled, which ruins interval timing.'
|
hint: 'Held the whole time the app is open, not just during a sequence. Background tabs get their timers throttled, which ruins interval timing.'
|
||||||
},
|
},
|
||||||
h('input', {
|
h('input', {
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
|||||||
147
examples/preact/sw.js
Normal file
147
examples/preact/sw.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* Service worker: makes the app installable and usable with no network.
|
||||||
|
*
|
||||||
|
* Offline matters more here than for most web apps - the camera is on the end
|
||||||
|
* of a USB cable, so the app is fully functional in a field with no signal, as
|
||||||
|
* long as it can load at all.
|
||||||
|
*
|
||||||
|
* Two caches with deliberately different strategies:
|
||||||
|
*
|
||||||
|
* - Our own files: network-first. They change whenever the app is redeployed,
|
||||||
|
* and a stale module paired with fresh HTML is a miserable bug to chase.
|
||||||
|
* Cache is the fallback, which is what makes offline work.
|
||||||
|
* - The unpkg dependencies (preact, web-gphoto2, the ~2MB WASM): cache-first.
|
||||||
|
* Those URLs are pinned to exact versions and are immutable, so re-checking
|
||||||
|
* them over the network buys nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERSION = 'v1';
|
||||||
|
const SHELL_CACHE = `intervalometer-shell-${VERSION}`;
|
||||||
|
const DEPS_CACHE = `intervalometer-deps-${VERSION}`;
|
||||||
|
const CACHES = [SHELL_CACHE, DEPS_CACHE];
|
||||||
|
|
||||||
|
const SHELL = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./index.js',
|
||||||
|
'./index-fallback.js',
|
||||||
|
'./home.js',
|
||||||
|
'./settings.js',
|
||||||
|
'./intervalometer.js',
|
||||||
|
'./storage.js',
|
||||||
|
'./config-utils.js',
|
||||||
|
'./preview.js',
|
||||||
|
'./voice.js',
|
||||||
|
'./widget.js',
|
||||||
|
'./icon-192.png',
|
||||||
|
'./icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Cross-origin hosts whose responses are safe to keep indefinitely. */
|
||||||
|
const IMMUTABLE_HOSTS = ['unpkg.com'];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These have to be fetched at install time, not left 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 - which
|
||||||
|
* means without this the app looks cached but dies offline on its imports.
|
||||||
|
*
|
||||||
|
* Every URL is version-pinned, so they're safe to hold forever.
|
||||||
|
*/
|
||||||
|
const DEPS = [
|
||||||
|
'https://unpkg.com/web-gphoto2@0.4.1/build/camera.js',
|
||||||
|
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.mjs',
|
||||||
|
'https://unpkg.com/web-gphoto2@0.4.1/build/libapi.wasm',
|
||||||
|
'https://unpkg.com/preact@10.6.4/dist/preact.module.js',
|
||||||
|
'https://unpkg.com/purecss@2.0.6/build/pure-min.css'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL)),
|
||||||
|
precacheDeps()
|
||||||
|
]).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetched individually and tolerantly: a flaky CDN shouldn't fail the whole
|
||||||
|
* install and leave the app with no worker at all. Anything that misses here
|
||||||
|
* gets picked up by the runtime cache on a later online visit.
|
||||||
|
*/
|
||||||
|
async function precacheDeps() {
|
||||||
|
let cache = await caches.open(DEPS_CACHE);
|
||||||
|
await Promise.allSettled(
|
||||||
|
DEPS.map(async url => {
|
||||||
|
let response = await fetch(url, { mode: 'cors', credentials: 'omit' });
|
||||||
|
if (!response.ok) throw new Error(`${response.status} for ${url}`);
|
||||||
|
await cache.put(url, response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then(keys =>
|
||||||
|
Promise.all(keys.filter(k => !CACHES.includes(k)).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
let { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
let url = new URL(request.url);
|
||||||
|
|
||||||
|
if (IMMUTABLE_HOSTS.includes(url.hostname)) {
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.origin === self.location.origin) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
let cache = await caches.open(DEPS_CACHE);
|
||||||
|
let hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
let response = await fetch(request);
|
||||||
|
// An opaque response would break the page's cross-origin isolation on
|
||||||
|
// replay, so only keep ones that actually passed CORS.
|
||||||
|
if (response.ok && response.type !== 'opaque') {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function networkFirst(request) {
|
||||||
|
let cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
let response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
let hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
// A navigation with nothing cached for this exact URL still wants the shell.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
let shell = await cache.match('./');
|
||||||
|
if (shell) return shell;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
// dist/ is a copy of these same files assembled for deploy; checking it too
|
// dist/ is a copy of these same files assembled for deploy; checking it too
|
||||||
// just reports every error twice, against stale copies.
|
// just reports every error twice, against stale copies.
|
||||||
"exclude": ["dist", "node_modules"],
|
// sw.js runs in ServiceWorkerGlobalScope, not the DOM, so checking it in this
|
||||||
|
// program reports every worker global as undefined.
|
||||||
|
"exclude": ["dist", "node_modules", "sw.js"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|||||||
10
examples/preact/types.d.ts
vendored
10
examples/preact/types.d.ts
vendored
@@ -6,8 +6,12 @@ type LogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppState = {
|
type AppState = {
|
||||||
/** Which top-level screen is showing. */
|
/**
|
||||||
view: 'status' | 'picker' | 'ready';
|
* Which top-level screen is showing. 'searching' auto-retries in the
|
||||||
|
* background; 'permission' is the one-off WebUSB grant, which the browser
|
||||||
|
* will not let us trigger without a click.
|
||||||
|
*/
|
||||||
|
view: 'status' | 'searching' | 'permission' | 'ready';
|
||||||
/** Shown while `view` is 'status'. */
|
/** Shown while `view` is 'status'. */
|
||||||
message?: string;
|
message?: string;
|
||||||
config?: import('web-gphoto2').Config;
|
config?: import('web-gphoto2').Config;
|
||||||
@@ -17,4 +21,6 @@ type AppState = {
|
|||||||
log: LogEntry[];
|
log: LogEntry[];
|
||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
singleShotStatus: 'idle' | 'busy';
|
singleShotStatus: 'idle' | 'busy';
|
||||||
|
/** Whether the preview pane is currently filling the screen. */
|
||||||
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user