@7365admin1/layer-common 4.56.0 → 4.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/assets/css/robot-hero.css +4 -0
- package/components/Robot/Detail.vue +5 -9
- package/components/Robot/LiveView.vue +151 -38
- package/components/Robot/SiteFleet.vue +1 -2
- package/package.json +2 -1
- package/utils/robot-control.ts +160 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.57.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d219308: Robots: fix the site fleet capability tooltip line break (a literal line break inside a string broke every consuming app build on 4.56.0/4.57.0). The SFC compile test now compiles each component's script as well as its template, so this kind of break fails CI here instead of in the apps.
|
|
8
|
+
|
|
9
|
+
## 4.57.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 611dd38: Robots: the live view shows the PadBot (EZVIZ) camera's H.265 picture. HLS now plays through hls.js wherever the browser has Media Source Extensions (native HLS only as the Safari fallback), the player owns `video.src` so a re-render can no longer reset the stream (the cause of the fatal bufferAppendError / black picture), and it recovers by itself: media-error recovery, then a fresh URL with backoff on network errors, expired URLs and stalls. When no picture can be shown it says so in plain words with a Retry button. The robot detail gains an inline Live view panel that plays automatically, with an "Always show live view" switch remembered per browser; it releases the stream while the tab is hidden, the panel is far off-screen or the robot is offline, and Full screen reuses the same stream.
|
|
14
|
+
|
|
3
15
|
## 4.56.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1587,11 +1599,11 @@
|
|
|
1587
1599
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1588
1600
|
own rule from two endpoints the console already calls, unprojected:
|
|
1589
1601
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1602
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1603
|
+
GET /api/roles/id/:role that membership's role document
|
|
1592
1604
|
|
|
1593
|
-
|
|
1594
|
-
|
|
1605
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1606
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1595
1607
|
|
|
1596
1608
|
`role.default` is the marker because it is the only property of a platform
|
|
1597
1609
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1273,3 +1273,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1273
1273
|
.hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1274
1274
|
.hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
|
|
1275
1275
|
.hero-pick__point:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
1276
|
+
.hero-livepanel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--hero-surface); border: 1px solid color-mix(in srgb, var(--hero-text) 12%, transparent); }
|
|
1277
|
+
.hero-livepanel .hero-live__head { flex-wrap: wrap; }
|
|
1278
|
+
.hero-livepanel .hero-dialog__actions { margin-top: 0; }
|
|
1279
|
+
.hero-live__stage .hero-notice { color: var(--hero-text); font-size: 13px; max-width: 90%; }
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
|
|
32
32
|
</div>
|
|
33
33
|
</div>
|
|
34
|
-
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="
|
|
34
|
+
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="livePanel?.show()">Live view</v-btn>
|
|
35
35
|
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
36
36
|
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
37
37
|
</div>
|
|
@@ -153,11 +153,10 @@
|
|
|
153
153
|
|
|
154
154
|
<div v-if="caps?.liveVideo === true || canDrive">
|
|
155
155
|
<div class="hero-section__title">Camera</div>
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
<v-btn
|
|
156
|
+
<RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
|
|
157
|
+
<div v-if="canDrive" class="hero-detail__video">
|
|
158
|
+
<v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
159
159
|
</div>
|
|
160
|
-
<div v-else class="hero-notreported">Live view opens when the robot is online.</div>
|
|
161
160
|
</div>
|
|
162
161
|
|
|
163
162
|
<v-dialog v-model="confirmOpen" width="440">
|
|
@@ -198,9 +197,6 @@
|
|
|
198
197
|
</div>
|
|
199
198
|
</v-dialog>
|
|
200
199
|
|
|
201
|
-
<v-dialog v-model="liveOpen" width="860">
|
|
202
|
-
<RobotLiveView v-if="liveOpen" :robot-id="robotId" :name="record?.name" @close="liveOpen = false" />
|
|
203
|
-
</v-dialog>
|
|
204
200
|
<v-dialog v-model="driveOpen" width="520" persistent>
|
|
205
201
|
<RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
|
|
206
202
|
</v-dialog>
|
|
@@ -600,7 +596,7 @@ function onPick(p: TSavedPoint) {
|
|
|
600
596
|
}
|
|
601
597
|
|
|
602
598
|
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
603
|
-
const
|
|
599
|
+
const livePanel = ref<{ show(): void } | null>(null);
|
|
604
600
|
const driveOpen = ref(false);
|
|
605
601
|
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
606
602
|
const canResolve = computed(
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
|
-
The robot's current camera stream
|
|
3
|
+
The robot's current camera stream (security robots, liveVideo).
|
|
4
|
+
Two shapes, one player: the pop-up dialog (`panel` unset) and the inline
|
|
5
|
+
panel on the robot detail (`panel`), which plays by itself, remembers
|
|
6
|
+
"Always show live view" per browser, and releases the stream while the tab
|
|
7
|
+
is hidden, the panel is far off-screen, or the robot is offline.
|
|
4
8
|
The URL is minted per open and re-minted shortly before it expires; it is
|
|
5
|
-
never stored.
|
|
9
|
+
never stored. Full screen reuses the same stream - never a second fetch.
|
|
6
10
|
-->
|
|
7
|
-
<div
|
|
11
|
+
<div
|
|
12
|
+
ref="root"
|
|
13
|
+
:class="panel ? 'hero-livepanel' : 'hero-dialog hero-live'"
|
|
14
|
+
:role="panel ? 'region' : 'dialog'"
|
|
15
|
+
:aria-labelledby="titleId"
|
|
16
|
+
>
|
|
8
17
|
<div class="hero-live__head">
|
|
9
|
-
<div id="
|
|
18
|
+
<div :id="titleId" class="hero-dialog__title">{{ panel ? "Live view" : `Live view · ${name ?? ""}` }}</div>
|
|
10
19
|
<span class="hero-head__spacer" />
|
|
11
|
-
<
|
|
20
|
+
<v-switch v-if="panel" v-model="on" label="Always show live view" density="compact" color="primary" hide-details inset />
|
|
21
|
+
<div v-if="on" class="hero-live__quality" role="group" aria-label="Quality">
|
|
12
22
|
<button type="button" :aria-pressed="quality === 'smooth'" @click="quality = 'smooth'">Smooth</button>
|
|
13
23
|
<button type="button" :aria-pressed="quality === 'hd'" @click="quality = 'hd'">HD</button>
|
|
14
24
|
</div>
|
|
15
25
|
</div>
|
|
16
26
|
|
|
17
|
-
<div class="hero-live__stage">
|
|
18
|
-
<div v-if="
|
|
27
|
+
<div v-if="on" class="hero-live__stage">
|
|
28
|
+
<div v-if="offline" class="hero-muted">Robot offline — live view resumes when it reconnects</div>
|
|
19
29
|
<div v-else-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
20
|
-
<
|
|
21
|
-
|
|
30
|
+
<div v-else-if="retrying" class="hero-muted" role="status">Reconnecting to the robot's camera…</div>
|
|
31
|
+
<div v-else-if="!active" class="hero-muted">Live view paused while it is off screen.</div>
|
|
32
|
+
<div v-else-if="!url" class="hero-muted" role="status">Connecting to the robot's camera…</div>
|
|
33
|
+
<template v-else>
|
|
34
|
+
<video v-if="plays" ref="videoEl" class="hero-live__video" controls autoplay muted playsinline />
|
|
22
35
|
<iframe
|
|
23
36
|
v-else-if="kind === 'page'"
|
|
24
37
|
:key="url"
|
|
@@ -40,56 +53,139 @@
|
|
|
40
53
|
</template>
|
|
41
54
|
</div>
|
|
42
55
|
|
|
43
|
-
<div class="hero-dialog__actions">
|
|
44
|
-
<
|
|
56
|
+
<div v-if="on || !panel" class="hero-dialog__actions">
|
|
57
|
+
<v-btn v-if="error && !offline" variant="outlined" size="small" @click="restart">Retry</v-btn>
|
|
58
|
+
<v-btn v-if="url && plays && !error" variant="outlined" size="small" @click="fullScreen">Full screen</v-btn>
|
|
59
|
+
<a v-if="url && !plays && kind !== 'rtmp'" class="hero-live__open" :href="url" target="_blank" rel="noopener noreferrer">Open in new tab</a>
|
|
45
60
|
<span class="hero-head__spacer" />
|
|
46
|
-
<v-btn variant="outlined" @click="emit('close')">Close</v-btn>
|
|
61
|
+
<v-btn v-if="!panel" variant="outlined" @click="emit('close')">Close</v-btn>
|
|
47
62
|
</div>
|
|
48
63
|
</div>
|
|
49
64
|
</template>
|
|
50
65
|
|
|
51
66
|
<script setup lang="ts">
|
|
52
|
-
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
53
|
-
import {
|
|
67
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
68
|
+
import {
|
|
69
|
+
LIVE_DOWN,
|
|
70
|
+
LIVE_NO_PICTURE,
|
|
71
|
+
LIVE_RETRY_LIMIT,
|
|
72
|
+
liveRetryDelayMs,
|
|
73
|
+
liveVideoKind,
|
|
74
|
+
liveViewActive,
|
|
75
|
+
readLivePref,
|
|
76
|
+
remintDelayMs,
|
|
77
|
+
startLivePlayer,
|
|
78
|
+
writeLivePref,
|
|
79
|
+
} from "../../utils/robot-control";
|
|
54
80
|
|
|
55
|
-
const props = defineProps<{ robotId: string; name?: string }>();
|
|
81
|
+
const props = defineProps<{ robotId: string; name?: string; panel?: boolean; offline?: boolean }>();
|
|
56
82
|
const emit = defineEmits<{ close: [] }>();
|
|
57
83
|
const { getLiveVideo } = useRobots();
|
|
58
84
|
|
|
59
|
-
const
|
|
85
|
+
const titleId = `hero-live-title-${Math.random().toString(36).slice(2, 8)}`;
|
|
86
|
+
// "Always show live view": one choice per browser, like a dashboard layout. ponytail: localStorage, no table.
|
|
87
|
+
const storage = () => (typeof localStorage === "undefined" ? undefined : localStorage);
|
|
88
|
+
const on = ref(props.panel ? readLivePref(storage()) : true);
|
|
89
|
+
watch(on, (v) => writeLivePref(storage(), v));
|
|
90
|
+
|
|
60
91
|
const quality = ref<"hd" | "smooth">("smooth");
|
|
61
92
|
const url = ref("");
|
|
62
93
|
const error = ref("");
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
const retrying = ref(false);
|
|
95
|
+
const pageVisible = ref(typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
96
|
+
const inView = ref(true);
|
|
97
|
+
const root = ref<HTMLElement | null>(null);
|
|
98
|
+
const videoEl = ref<HTMLVideoElement | null>(null);
|
|
99
|
+
const active = computed(() => liveViewActive({ on: on.value, offline: props.offline, visible: pageVisible.value, inView: inView.value }));
|
|
67
100
|
const kind = computed(() => liveVideoKind(url.value));
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
kind.value === "video" ||
|
|
71
|
-
(kind.value === "hls" && typeof document !== "undefined" && !!document.createElement("video").canPlayType("application/vnd.apple.mpegurl")),
|
|
72
|
-
);
|
|
101
|
+
// HLS always goes to the player: hls.js where the browser has MSE, native HLS otherwise.
|
|
102
|
+
const plays = computed(() => kind.value === "video" || kind.value === "hls");
|
|
73
103
|
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let gen = 0; // bumps on every mint/release so a late answer for an old quality/robot is dropped
|
|
106
|
+
let attempts = 0;
|
|
107
|
+
let stopPlayer: (() => void) | undefined;
|
|
108
|
+
|
|
109
|
+
function stopStream() {
|
|
110
|
+
stopPlayer?.();
|
|
111
|
+
stopPlayer = undefined;
|
|
112
|
+
}
|
|
113
|
+
/** Free everything: player, connection, timers, pending answers. */
|
|
114
|
+
function release() {
|
|
115
|
+
gen++;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
stopStream();
|
|
118
|
+
url.value = "";
|
|
119
|
+
retrying.value = false;
|
|
120
|
+
}
|
|
74
121
|
async function mint() {
|
|
122
|
+
const my = ++gen;
|
|
75
123
|
clearTimeout(timer);
|
|
76
|
-
loading.value = true;
|
|
77
124
|
error.value = "";
|
|
78
125
|
try {
|
|
79
126
|
const res = await getLiveVideo(props.robotId, quality.value);
|
|
80
|
-
if (
|
|
81
|
-
if (liveVideoKind(res?.url) === "bad") throw new Error(
|
|
127
|
+
if (my !== gen) return;
|
|
128
|
+
if (liveVideoKind(res?.url) === "bad") throw new Error(LIVE_DOWN);
|
|
82
129
|
url.value = res.url;
|
|
83
130
|
const wait = remintDelayMs(res?.expiresAt);
|
|
84
131
|
if (wait !== null) timer = setTimeout(mint, wait);
|
|
85
132
|
} catch {
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
133
|
+
if (my === gen) reconnectLater();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Automatic reconnect with backoff; after LIVE_RETRY_LIMIT tries, stop and offer Retry. */
|
|
137
|
+
function reconnectLater() {
|
|
138
|
+
const my = ++gen;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
stopStream();
|
|
141
|
+
url.value = "";
|
|
142
|
+
if (attempts >= LIVE_RETRY_LIMIT) {
|
|
143
|
+
retrying.value = false;
|
|
144
|
+
error.value = LIVE_DOWN;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
retrying.value = true;
|
|
148
|
+
timer = setTimeout(() => {
|
|
149
|
+
if (my !== gen) return;
|
|
150
|
+
retrying.value = false;
|
|
151
|
+
mint();
|
|
152
|
+
}, liveRetryDelayMs(attempts++));
|
|
153
|
+
}
|
|
154
|
+
/** Clean slate: destroy the player and, when showing, fetch a fresh URL. Also the Retry button. */
|
|
155
|
+
function restart() {
|
|
156
|
+
release();
|
|
157
|
+
attempts = 0;
|
|
158
|
+
error.value = "";
|
|
159
|
+
if (active.value) mint();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The player starts only when the <video> exists and a URL is in hand, and is
|
|
163
|
+
// re-created only when either changes - never on an ordinary re-render.
|
|
164
|
+
watch([videoEl, url], ([el, src]) => {
|
|
165
|
+
stopStream();
|
|
166
|
+
if (!el || !src) return;
|
|
167
|
+
stopPlayer = startLivePlayer(el, src, onPlayerFail, { onPicture: () => (attempts = 0) });
|
|
168
|
+
});
|
|
169
|
+
function onPlayerFail(message: string) {
|
|
170
|
+
stopPlayer = undefined;
|
|
171
|
+
if (message === LIVE_NO_PICTURE) {
|
|
172
|
+
// The browser can't show H.265: no silent retries behind the message; Retry is the user's.
|
|
173
|
+
gen++;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
error.value = message;
|
|
176
|
+
return;
|
|
92
177
|
}
|
|
178
|
+
reconnectLater(); // network, expired URL, stall: fetch a fresh URL and go again
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
watch(active, restart, { immediate: true });
|
|
182
|
+
watch([quality, () => props.robotId], restart);
|
|
183
|
+
|
|
184
|
+
function fullScreen() {
|
|
185
|
+
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null;
|
|
186
|
+
if (!v) return;
|
|
187
|
+
if (v.requestFullscreen) v.requestFullscreen().catch(() => {});
|
|
188
|
+
else v.webkitEnterFullscreen?.();
|
|
93
189
|
}
|
|
94
190
|
async function copy() {
|
|
95
191
|
try {
|
|
@@ -98,10 +194,27 @@ async function copy() {
|
|
|
98
194
|
/* the link is still shown by the browser's own copy */
|
|
99
195
|
}
|
|
100
196
|
}
|
|
101
|
-
|
|
197
|
+
|
|
198
|
+
const onVisibility = () => (pageVisible.value = document.visibilityState !== "hidden");
|
|
199
|
+
let observer: IntersectionObserver | undefined;
|
|
200
|
+
onMounted(() => {
|
|
201
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
202
|
+
if (props.panel && root.value && typeof IntersectionObserver !== "undefined") {
|
|
203
|
+
// "Far" off-screen = more than a screen's height away; scrolling briefly past keeps it playing.
|
|
204
|
+
observer = new IntersectionObserver(([e]) => (inView.value = !!e?.isIntersecting), { rootMargin: "100% 0px" });
|
|
205
|
+
observer.observe(root.value);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
/** For the detail page's "Live view" buttons: switch the panel on and bring it into view. */
|
|
209
|
+
defineExpose({
|
|
210
|
+
show() {
|
|
211
|
+
on.value = true;
|
|
212
|
+
root.value?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
213
|
+
},
|
|
214
|
+
});
|
|
102
215
|
onBeforeUnmount(() => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
216
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
217
|
+
observer?.disconnect();
|
|
218
|
+
release();
|
|
106
219
|
});
|
|
107
220
|
</script>
|
|
@@ -235,8 +235,7 @@ function abilityTip(row: any) {
|
|
|
235
235
|
const s = live(row);
|
|
236
236
|
return robotAbilities({ capabilities: capsOf(row), profile: s?.ok ? s.profile : null, type: row.type, brand: brandName(row.brand, brands.value) })
|
|
237
237
|
.map((r) => `${r.label}: ${r.value}`)
|
|
238
|
-
.join("
|
|
239
|
-
");
|
|
238
|
+
.join("\n");
|
|
240
239
|
}
|
|
241
240
|
/** The live fault when the robot answers, else its stored open fault. */
|
|
242
241
|
const faultOf = (row: any) => (live(row)?.ok ? live(row)?.fault ?? null : row.fault ?? null);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.57.1",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@mdi/font": "^7.4.47",
|
|
58
58
|
"@types/qrcode": "^1.5.6",
|
|
59
59
|
"ckeditor5": "47.4.0",
|
|
60
|
+
"hls.js": "^1.6.13",
|
|
60
61
|
"html2pdf.js": "^0.10.2",
|
|
61
62
|
"html5-qrcode": "^2.3.8",
|
|
62
63
|
"moment-timezone": "^0.6.0",
|
package/utils/robot-control.ts
CHANGED
|
@@ -11,9 +11,9 @@ export type TVideoKind = "hls" | "video" | "flv" | "rtmp" | "page" | "bad";
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* What kind of stream URL the robot's cloud handed us.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* PadBot sends HLS (MPEG-TS, H.265 video + AAC audio). HLS plays through
|
|
15
|
+
* hls.js wherever the browser has Media Source Extensions, native HLS
|
|
16
|
+
* otherwise (Safari). FLV gets "Open in new tab".
|
|
17
17
|
*/
|
|
18
18
|
export function liveVideoKind(url: unknown): TVideoKind {
|
|
19
19
|
if (typeof url !== "string") return "bad";
|
|
@@ -33,6 +33,163 @@ export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): num
|
|
|
33
33
|
return Math.max(5_000, t - now - 30_000);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export const LIVE_NO_PICTURE =
|
|
37
|
+
"This robot's camera sends H.265 video, which this browser can't show. Try Microsoft Edge or Safari, or the PadBot app.";
|
|
38
|
+
export const LIVE_DOWN = "The robot's camera isn't available right now.";
|
|
39
|
+
|
|
40
|
+
/** Wait before automatic reconnect attempt n (0-based): 2 s, 4 s, 8 s, 16 s, then 30 s. */
|
|
41
|
+
export const liveRetryDelayMs = (n: number) => Math.min(30_000, 2_000 * 2 ** n);
|
|
42
|
+
/** Automatic reconnects before the dialog stops and offers Retry. */
|
|
43
|
+
export const LIVE_RETRY_LIMIT = 6;
|
|
44
|
+
|
|
45
|
+
type THls = {
|
|
46
|
+
on(event: string, fn: (event: string, data: { fatal?: boolean; type?: string; details?: string }) => void): void;
|
|
47
|
+
loadSource(url: string): void;
|
|
48
|
+
attachMedia(video: HTMLVideoElement): void;
|
|
49
|
+
recoverMediaError(): void;
|
|
50
|
+
swapAudioCodec(): void;
|
|
51
|
+
destroy(): void;
|
|
52
|
+
};
|
|
53
|
+
export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls };
|
|
54
|
+
|
|
55
|
+
/** The inline panel streams only while it is switched on, the robot is online, the tab is visible and the panel is near the screen. */
|
|
56
|
+
export const liveViewActive = (s: { on: boolean; offline?: boolean; visible: boolean; inView: boolean }) =>
|
|
57
|
+
s.on && !s.offline && s.visible && s.inView;
|
|
58
|
+
|
|
59
|
+
/** "Always show live view", remembered per browser (default on). Storage can throw (private window, blocked site data). */
|
|
60
|
+
const LIVE_PREF_KEY = "hero-robot-live-view";
|
|
61
|
+
export function readLivePref(storage: Pick<Storage, "getItem"> | undefined): boolean {
|
|
62
|
+
try {
|
|
63
|
+
return storage?.getItem(LIVE_PREF_KEY) !== "off";
|
|
64
|
+
} catch {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function writeLivePref(storage: Pick<Storage, "setItem"> | undefined, on: boolean) {
|
|
69
|
+
try {
|
|
70
|
+
storage?.setItem(LIVE_PREF_KEY, on ? "on" : "off");
|
|
71
|
+
} catch {
|
|
72
|
+
/* the switch still works for this visit */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** hls.js errors that mean "this browser cannot decode the codec", not "the camera is down". */
|
|
77
|
+
const CODEC_ERRORS = new Set(["manifestIncompatibleCodecsError", "bufferAddCodecError", "bufferIncompatibleCodecsError"]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Plays one live-view URL into `video`. Returns a stop function that frees the
|
|
81
|
+
* player and the connection.
|
|
82
|
+
*
|
|
83
|
+
* `video.src` is owned here and must never be bound by the template: a
|
|
84
|
+
* re-render that re-sets `src` under hls.js detaches its MediaSource, and
|
|
85
|
+
* hls.js 1.6 then fails with a fatal bufferAppendError ("SourceBuffer has been
|
|
86
|
+
* removed") and a black picture - the 2026-09-26 production bug.
|
|
87
|
+
*
|
|
88
|
+
* `onFail` fires once, with LIVE_NO_PICTURE when the stream plays but no
|
|
89
|
+
* picture ever decodes (Chrome's native HLS takes H.265-in-TS audio but shows
|
|
90
|
+
* no video, videoWidth stays 0) or the codec is refused; LIVE_DOWN for
|
|
91
|
+
* everything else (network, expired URL, repeated media errors, a stall) -
|
|
92
|
+
* the caller fetches a fresh URL and starts again.
|
|
93
|
+
*/
|
|
94
|
+
export function startLivePlayer(
|
|
95
|
+
video: HTMLVideoElement,
|
|
96
|
+
url: string,
|
|
97
|
+
onFail: (message: string) => void,
|
|
98
|
+
opts: {
|
|
99
|
+
loadHls?: () => Promise<THlsCtor>;
|
|
100
|
+
hasMse?: boolean;
|
|
101
|
+
onPicture?: () => void;
|
|
102
|
+
noPictureMs?: number;
|
|
103
|
+
stallMs?: number;
|
|
104
|
+
tickMs?: number;
|
|
105
|
+
} = {},
|
|
106
|
+
): () => void {
|
|
107
|
+
const noPictureMs = opts.noPictureMs ?? 15_000;
|
|
108
|
+
const stallMs = opts.stallMs ?? 20_000;
|
|
109
|
+
const tickMs = opts.tickMs ?? 1_000;
|
|
110
|
+
let stopped = false;
|
|
111
|
+
let hls: THls | undefined;
|
|
112
|
+
let waited = 0;
|
|
113
|
+
let pictured = false;
|
|
114
|
+
let lastTime = -1;
|
|
115
|
+
let still = 0;
|
|
116
|
+
|
|
117
|
+
const fail = (message: string) => {
|
|
118
|
+
if (stopped) return;
|
|
119
|
+
stop();
|
|
120
|
+
onFail(message);
|
|
121
|
+
};
|
|
122
|
+
// ponytail: one 1 s poll instead of juggling media events - videoWidth has no reliable event everywhere.
|
|
123
|
+
const tick = setInterval(() => {
|
|
124
|
+
waited += tickMs;
|
|
125
|
+
if (!pictured) {
|
|
126
|
+
if (video.videoWidth > 0) {
|
|
127
|
+
pictured = true;
|
|
128
|
+
opts.onPicture?.();
|
|
129
|
+
} else if (waited >= noPictureMs) {
|
|
130
|
+
// Time moving with no picture = the codec; nothing moving = no stream.
|
|
131
|
+
return fail(video.currentTime > 0 ? LIVE_NO_PICTURE : LIVE_DOWN);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (video.paused) return (still = 0);
|
|
136
|
+
if (video.currentTime !== lastTime) {
|
|
137
|
+
lastTime = video.currentTime;
|
|
138
|
+
still = 0;
|
|
139
|
+
} else if ((still += tickMs) >= stallMs) fail(LIVE_DOWN);
|
|
140
|
+
}, tickMs);
|
|
141
|
+
const onError = () => fail(LIVE_DOWN);
|
|
142
|
+
|
|
143
|
+
function stop() {
|
|
144
|
+
stopped = true;
|
|
145
|
+
clearInterval(tick);
|
|
146
|
+
video.removeEventListener("error", onError);
|
|
147
|
+
hls?.destroy();
|
|
148
|
+
hls = undefined;
|
|
149
|
+
video.removeAttribute("src");
|
|
150
|
+
video.load();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function native() {
|
|
154
|
+
video.addEventListener("error", onError);
|
|
155
|
+
video.src = url;
|
|
156
|
+
video.play().catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const mse =
|
|
160
|
+
opts.hasMse ?? (typeof window !== "undefined" && !!((window as any).MediaSource || (window as any).ManagedMediaSource));
|
|
161
|
+
if (liveVideoKind(url) !== "hls" || !mse) {
|
|
162
|
+
native();
|
|
163
|
+
} else {
|
|
164
|
+
(opts.loadHls ?? (() => import("hls.js").then((m) => m.default as unknown as THlsCtor)))()
|
|
165
|
+
.then((Hls) => {
|
|
166
|
+
if (stopped) return;
|
|
167
|
+
if (!Hls.isSupported()) return native();
|
|
168
|
+
let recoveries = 0;
|
|
169
|
+
const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false }));
|
|
170
|
+
h.on("hlsError", (_e, data) => {
|
|
171
|
+
if (!data.fatal) return;
|
|
172
|
+
if (CODEC_ERRORS.has(data.details ?? "")) return fail(LIVE_NO_PICTURE);
|
|
173
|
+
// The standard hls.js ladder: recover, then swap audio codec and recover, then give up.
|
|
174
|
+
if (data.type === "mediaError" && recoveries < 2) {
|
|
175
|
+
if (recoveries++ === 1) h.swapAudioCodec();
|
|
176
|
+
return h.recoverMediaError();
|
|
177
|
+
}
|
|
178
|
+
fail(LIVE_DOWN);
|
|
179
|
+
});
|
|
180
|
+
h.loadSource(url);
|
|
181
|
+
h.attachMedia(video);
|
|
182
|
+
video.play().catch(() => {});
|
|
183
|
+
})
|
|
184
|
+
.catch(() => {
|
|
185
|
+
if (!stopped) native();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return () => {
|
|
189
|
+
if (!stopped) stop();
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
36
193
|
/* ======================================================== manual drive */
|
|
37
194
|
|
|
38
195
|
export type TDriveLimits = {
|