@7365admin1/layer-common 4.56.0 → 4.57.0

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
3
9
  ## 4.56.0
4
10
 
5
11
  ### Minor Changes
@@ -1587,11 +1593,11 @@
1587
1593
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1588
1594
  own rule from two endpoints the console already calls, unprojected:
1589
1595
 
1590
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1591
- GET /api/roles/id/:role that membership's role document
1596
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1597
+ GET /api/roles/id/:role that membership's role document
1592
1598
 
1593
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1594
- staff = member.type === "admin" && role.type === "admin"
1599
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1600
+ staff = member.type === "admin" && role.type === "admin"
1595
1601
 
1596
1602
  `role.default` is the marker because it is the only property of a platform
1597
1603
  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="liveOpen = true">Live view</v-btn>
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
- <div v-if="online || canDrive" class="hero-detail__video">
157
- <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" @click="liveOpen = true">Live view</v-btn>
158
- <v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</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 liveOpen = ref(false);
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, in the page (security robots, liveVideo).
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. What the browser cannot play in place opens in a new tab.
9
+ never stored. Full screen reuses the same stream - never a second fetch.
6
10
  -->
7
- <div class="hero-dialog hero-live" role="dialog" aria-labelledby="hero-live-title">
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="hero-live-title" class="hero-dialog__title">Live view · {{ name }}</div>
18
+ <div :id="titleId" class="hero-dialog__title">{{ panel ? "Live view" : `Live view · ${name ?? ""}` }}</div>
10
19
  <span class="hero-head__spacer" />
11
- <div class="hero-live__quality" role="group" aria-label="Quality">
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="loading && !url" class="hero-muted">Connecting to the robot's camera…</div>
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
- <template v-else-if="url">
21
- <video v-if="plays" :key="url" class="hero-live__video" :src="url" controls autoplay muted playsinline @error="error = CAMERA_DOWN" />
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
- <a v-if="url && kind !== 'rtmp'" class="hero-live__open" :href="url" target="_blank" rel="noopener noreferrer">Open in new tab</a>
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 { liveVideoKind, remintDelayMs } from "../../utils/robot-control";
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 CAMERA_DOWN = "The robot's camera isn't available right now.";
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 loading = ref(false);
64
- let timer: ReturnType<typeof setTimeout> | undefined;
65
- let alive = true;
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
- const plays = computed(
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 (!alive) return;
81
- if (liveVideoKind(res?.url) === "bad") throw new Error(CAMERA_DOWN);
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 (alive) {
87
- url.value = "";
88
- error.value = CAMERA_DOWN;
89
- }
90
- } finally {
91
- loading.value = false;
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
- watch(quality, mint, { immediate: true });
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
- alive = false;
104
- clearTimeout(timer);
105
- url.value = "";
216
+ document.removeEventListener("visibilitychange", onVisibility);
217
+ observer?.disconnect();
218
+ release();
106
219
  });
107
220
  </script>
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.56.0",
5
+ "version": "4.57.0",
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",
@@ -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
- * ponytail: no hls.js / mpegts.js yet - the real PadBot URL type is unknown;
15
- * HLS plays natively where the browser can (Safari), FLV and HLS elsewhere
16
- * get "Open in new tab". Add the player library once a real URL is seen.
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 = {