diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts index 5447f24..cd4c0d6 100644 --- a/apps/web/composables/usePrinterStream.ts +++ b/apps/web/composables/usePrinterStream.ts @@ -1,7 +1,13 @@ /** * 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. */ cameraId: string; /** diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 18c4bfc..9525e34 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -56,6 +56,11 @@ async function signalOffer(printerId: string, sdp: string): Promise { return answer.sdp; } +/** One camera frame (P1/A1) as an image, through the backend. */ +function fetchFrame(printerId: string): Promise { + return apiFetch(`/printers/${printerId}/snapshot`, { responseType: "blob" }); +} + // Which farms exist, and for each whether its gateway is connected and what // discovery has found. Polled, because gateway presence has no push channel; // ten seconds is quick enough to notice an unplugged box without hammering. @@ -119,7 +124,7 @@ watch( printers, (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 // 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 @@ -149,6 +154,7 @@ watch( :printer="current(printer)" :stream="streams[printer.id]" :signal="(sdp: string) => signalOffer(printer.id, sdp)" + :fetch-frame="() => fetchFrame(printer.id)" :send="(command: PrinterCommand) => sendCommand(printer.id, command)" /> diff --git a/packages/ui/src/components/PrinterCard.vue b/packages/ui/src/components/PrinterCard.vue index 5dbde63..47fec76 100644 --- a/packages/ui/src/components/PrinterCard.vue +++ b/packages/ui/src/components/PrinterCard.vue @@ -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; + /** Fetches one camera frame, for a `kind: "snapshot"` stream. */ + fetchFrame?: () => Promise; }>(); // `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" }}

+ +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; + /** Pause between the end of one fetch and the start of the next. */ + intervalMs?: number; +}>(); + +const src = ref(null); +const status = ref<"connecting" | "connected" | "failed">("connecting"); +const error = ref(null); + +let stopped = false; +let timer: ReturnType | 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); +}); + + + + + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4c0b069..9711b38 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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"; diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 1b51692..7936ee4 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -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; }