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
+13 -6
View File
@@ -3,6 +3,7 @@ import { computed } from "vue";
import type { AppPrinterState, PrinterCommand } from "../types";
import PrinterControls from "./PrinterControls.vue";
import TemperatureGraph from "./TemperatureGraph.vue";
import SnapshotView from "./SnapshotView.vue";
import WebRTCStreamView from "./WebRTCStreamView.vue";
/**
@@ -17,11 +18,10 @@ import WebRTCStreamView from "./WebRTCStreamView.vue";
* Undefined for most printers and that is expected - see
* `usePrinterStream`. The player is simply not rendered.
*/
interface PrinterStream {
cameraId: string;
iceServers: RTCIceServer[];
turnConfigured: boolean;
}
type PrinterStream =
| { kind?: "webrtc"; cameraId: string; iceServers: RTCIceServer[]; turnConfigured: boolean }
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
| { kind: "snapshot"; intervalMs?: number };
const props = defineProps<{
/**
@@ -49,6 +49,8 @@ const props = defineProps<{
* read-only embedding of the card cannot offer buttons that do nothing.
*/
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.
@@ -88,8 +90,13 @@ const lastSeen = computed(() => {
{{ lastSeen ? `Last heard from ${lastSeen}` : "Never reported" }}
</p>
<SnapshotView
v-if="stream && stream.kind === 'snapshot' && fetchFrame"
:fetch-frame="fetchFrame"
:interval-ms="stream.intervalMs"
/>
<WebRTCStreamView
v-if="stream && signal"
v-else-if="stream && stream.kind !== 'snapshot' && signal"
:camera-id="stream.cameraId"
:ice-servers="stream.iceServers"
: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 TemperatureGraph } from "./components/TemperatureGraph.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 JobUpload } from "./components/JobUpload.vue";
export { default as AlertList } from "./components/AlertList.vue";
+2
View File
@@ -40,6 +40,8 @@ export interface AppPrinterState {
activeAlerts?: number | null;
/** go2rtc stream name; null/absent means render no player. */
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. */
lastSeenAt?: string | null;
}