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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user