Show P1/A1 cameras as a refreshing picture

SnapshotView fetches one frame at a time through the backend, pauses
while the tab is hidden and backs off after failures. PrinterCard picks
it when /stream answers kind "snapshot".
This commit is contained in:
2026-10-29 16:11:10 +00:00
parent 9bd9728b21
commit 6b1c7c6db3
6 changed files with 139 additions and 8 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
/** /**
* Where a printer's camera can be watched, from `GET /printers/:id/stream`. * Where a printer's camera can be watched, from `GET /printers/:id/stream`.
*/ */
export interface PrinterStream { export type PrinterStream =
| WebrtcStream
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
| { kind: "snapshot"; snapshotPath: string; intervalMs: number };
export interface WebrtcStream {
kind?: "webrtc";
/** The go2rtc stream name to negotiate for. */ /** The go2rtc stream name to negotiate for. */
cameraId: string; cameraId: string;
/** /**
+7 -1
View File
@@ -56,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise<string> {
return answer.sdp; return answer.sdp;
} }
/** One camera frame (P1/A1) as an image, through the backend. */
function fetchFrame(printerId: string): Promise<Blob> {
return apiFetch<Blob>(`/printers/${printerId}/snapshot`, { responseType: "blob" });
}
// Which farms exist, and for each whether its gateway is connected and what // Which farms exist, and for each whether its gateway is connected and what
// discovery has found. Polled, because gateway presence has no push channel; // discovery has found. Polled, because gateway presence has no push channel;
// ten seconds is quick enough to notice an unplugged box without hammering. // ten seconds is quick enough to notice an unplugged box without hammering.
@@ -119,7 +124,7 @@ watch(
printers, printers,
(list) => { (list) => {
for (const printer of list ?? []) { for (const printer of list ?? []) {
if (!printer.cameraStream || printer.id in streams.value) continue; if (!(printer.cameraStream || printer.cameraSnapshot) || printer.id in streams.value) continue;
// Marked before the request resolves, so a re-render in the meantime // Marked before the request resolves, so a re-render in the meantime
// does not fire a second lookup for the same printer. `null` renders // does not fire a second lookup for the same printer. `null` renders
// no player, which is also the right thing to show while this is in // no player, which is also the right thing to show while this is in
@@ -149,6 +154,7 @@ watch(
:printer="current(printer)" :printer="current(printer)"
:stream="streams[printer.id]" :stream="streams[printer.id]"
:signal="(sdp: string) => signalOffer(printer.id, sdp)" :signal="(sdp: string) => signalOffer(printer.id, sdp)"
:fetch-frame="() => fetchFrame(printer.id)"
:send="(command: PrinterCommand) => sendCommand(printer.id, command)" :send="(command: PrinterCommand) => sendCommand(printer.id, command)"
/> />
</div> </div>
+13 -6
View File
@@ -3,6 +3,7 @@ import { computed } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types"; import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue"; import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue"; import TemperatureGraph from "./TemperatureGraph.vue";
import SnapshotView from "./SnapshotView.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue"; import WebRTCStreamView from "./WebRTCStreamView.vue";
/** /**
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
* Undefined for most printers and that is expected - see * Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered. * `usePrinterStream`. The player is simply not rendered.
*/ */
interface PrinterStream { type PrinterStream =
cameraId: string; | { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
iceServers: RTCIceServer[]; // A P1/A1: no stream to negotiate, a picture to fetch again and again.
turnConfigured: boolean; | { kind: "snapshot"; intervalMs?: number };
}
const props = defineProps<{ const props = defineProps<{
/** /**
@@ -49,6 +49,8 @@ const props = defineProps<{
* read-only embedding of the card cannot offer buttons that do nothing. * read-only embedding of the card cannot offer buttons that do nothing.
*/ */
send?: (command: PrinterCommand) => Promise<void>; send?: (command: PrinterCommand) => Promise<void>;
/** Fetches one camera frame, for a `kind: "snapshot"` stream. */
fetchFrame?: () => Promise<Blob>;
}>(); }>();
// `status` is open-ended and null for a printer that has never reported. // `status` is open-ended and null for a printer that has never reported.
@@ -88,8 +90,13 @@ const lastSeen = computed(() => {
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }} {{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
</p> </p>
<SnapshotView
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
:fetch-frame="fetchFrame"
:interval-ms="stream.intervalMs"
/>
<WebRTCStreamView <WebRTCStreamView
v-if="stream && signal" v-else-if="stream && stream.kind !== 'snapshot' && signal"
:camera-id="stream.cameraId" :camera-id="stream.cameraId"
:ice-servers="stream.iceServers" :ice-servers="stream.iceServers"
:turn-configured="stream.turnConfigured" :turn-configured="stream.turnConfigured"
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps<{
/**
* Fetches the next frame as an image. Supplied by the app, which knows the
* API; this package does not.
*/
fetchFrame: () => Promise<Blob>;
/** Pause between the end of one fetch and the start of the next. */
intervalMs?: number;
}>();
const src = ref<string | null>(null);
const status = ref<"connecting" | "connected" | "failed">("connecting");
const error = ref<string | null>(null);
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
/**
* One request at a time, the next only after the last has finished: a slow
* printer then slows the picture down instead of piling requests onto a
* camera that serves one client. Skipped while the tab is hidden, and
* backed off after failures, because every attempt logs in to the printer.
*/
let failures = 0;
async function tick() {
if (stopped) return;
let delay = props.intervalMs ?? 1000;
if (document.hidden) {
timer = setTimeout(tick, delay);
return;
}
try {
const blob = await props.fetchFrame();
if (stopped) return;
const next = URL.createObjectURL(blob);
if (src.value) URL.revokeObjectURL(src.value);
src.value = next;
status.value = "connected";
error.value = null;
failures = 0;
} catch (err) {
failures += 1;
status.value = "failed";
error.value = err instanceof Error ? err.message : String(err);
delay = Math.min(30_000, delay * 2 ** Math.min(failures, 5));
}
if (!stopped) timer = setTimeout(tick, delay);
}
onMounted(tick);
onBeforeUnmount(() => {
stopped = true;
clearTimeout(timer);
if (src.value) URL.revokeObjectURL(src.value);
});
</script>
<template>
<div class="snapshot-view">
<img v-if="src" :src="src" alt="Printer camera" />
<div v-else class="snapshot-view__empty">{{ status === "failed" ? "No picture" : "Connecting…" }}</div>
<span class="snapshot-view__status" :class="`is-${status}`" :title="error ?? undefined">
{{ status }}
</span>
</div>
</template>
<style scoped>
.snapshot-view {
position: relative;
background: #000;
border-radius: 6px;
overflow: hidden;
min-height: 80px;
}
.snapshot-view img {
width: 100%;
display: block;
}
.snapshot-view__empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8rem;
color: #999;
}
.snapshot-view__status {
position: absolute;
bottom: 6px;
right: 8px;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
}
.snapshot-view__status.is-connected {
color: #35d07f;
}
.snapshot-view__status.is-failed {
color: #f2604c;
}
</style>
+1
View File
@@ -2,6 +2,7 @@ export { default as PrinterCard } from "./components/PrinterCard.vue";
export { default as PrinterControls } from "./components/PrinterControls.vue"; export { default as PrinterControls } from "./components/PrinterControls.vue";
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue"; export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue"; export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
export { default as SnapshotView } from "./components/SnapshotView.vue";
export { default as JobList } from "./components/JobList.vue"; export { default as JobList } from "./components/JobList.vue";
export { default as JobUpload } from "./components/JobUpload.vue"; export { default as JobUpload } from "./components/JobUpload.vue";
export { default as AlertList } from "./components/AlertList.vue"; export { default as AlertList } from "./components/AlertList.vue";
+2
View File
@@ -40,6 +40,8 @@ export interface AppPrinterState {
activeAlerts?: number | null; activeAlerts?: number | null;
/** go2rtc stream name; null/absent means render no player. */ /** go2rtc stream name; null/absent means render no player. */
cameraStream?: string | null; cameraStream?: string | null;
/** True for a P1/A1: the camera is a refreshing picture, not a stream. */
cameraSnapshot?: boolean;
/** When a gateway last reported this printer; null means never. */ /** When a gateway last reported this printer; null means never. */
lastSeenAt?: string | null; lastSeenAt?: string | null;
} }