@camstack/ui-library 1.2.189 → 1.2.190

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/dist/index.cjs CHANGED
@@ -7183,6 +7183,8 @@ var useValveGetStatus = trpc.valve.getStatus.useQuery;
7183
7183
  var useVibrationGetStatus = trpc.vibration.getStatus.useQuery;
7184
7184
  /** Generated alias around `trpc.videoclips.listClips.useQuery`. */
7185
7185
  var useVideoclipsListClips = trpc.videoclips.listClips.useQuery;
7186
+ /** Generated alias around `trpc.videoclips.listSources.useQuery`. */
7187
+ var useVideoclipsListSources = trpc.videoclips.listSources.useQuery;
7186
7188
  /** Generated alias around `trpc.videoclips.getClipPlayback.useQuery`. */
7187
7189
  var useVideoclipsGetClipPlayback = trpc.videoclips.getClipPlayback.useQuery;
7188
7190
  /** Generated alias around `trpc.waterHeater.setTargetTemp.useMutation`. */
@@ -17573,6 +17575,774 @@ function ConsumablesPanel({ deviceId }) {
17573
17575
  });
17574
17576
  }
17575
17577
  //#endregion
17578
+ //#region src/hooks/use-capability-presence.ts
17579
+ /**
17580
+ * Classify a settled registry answer.
17581
+ *
17582
+ * `null` is "no such capability". A row with an empty `providers` list is a
17583
+ * capability that is DECLARED but that no addon supplies — indistinguishable
17584
+ * to an operator from not having it, and the panel behind it would have
17585
+ * nothing to call. Both are `absent`.
17586
+ *
17587
+ * Exported for the unit test: the mapping is the whole decision, and it is
17588
+ * worth pinning without a React tree around it.
17589
+ */
17590
+ function classifyCapability(row) {
17591
+ if (row === null) return "absent";
17592
+ return row.providers.length > 0 ? "present" : "absent";
17593
+ }
17594
+ /**
17595
+ * Ask the hub's capability registry whether `capabilityName` has a provider.
17596
+ *
17597
+ * The four states map straight off React Query's `status`, so there is no
17598
+ * place for a nullish fallback to turn "no answer" into "no providers" — the
17599
+ * exact collapse this hook exists to make unrepresentable.
17600
+ */
17601
+ function useCapabilityPresence(capabilityName) {
17602
+ const query = trpc.capabilities.getCapability.useQuery({ name: capabilityName });
17603
+ if (query.status === "pending") return "pending";
17604
+ if (query.status === "error") return "unknown";
17605
+ return classifyCapability(query.data);
17606
+ }
17607
+ //#endregion
17608
+ //#region src/contexts/vod-playback.tsx
17609
+ /**
17610
+ * VodPlayback — shared context coordinating recorded (VOD) playback between a
17611
+ * widget/section (the Recording panel) and the hero StreamPanel.
17612
+ *
17613
+ * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
17614
+ * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
17615
+ * through the hub data-plane) instead of the live WebRTC stream. `clear()`
17616
+ * returns the hero to live.
17617
+ *
17618
+ * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
17619
+ * the Module-Federation boundary: an addon-shipped widget consuming
17620
+ * `useVodPlayback()` resolves the SAME provider instance the host mounted —
17621
+ * the same mechanism `player-overlays` / `zone-editing` use to let federated
17622
+ * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
17623
+ * outside a provider get the inert default (no-op), so a StreamPanel mounted
17624
+ * on a page without the Recording widget stays live-only without crashing.
17625
+ */
17626
+ var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
17627
+ request: null,
17628
+ play: () => {},
17629
+ clear: () => {}
17630
+ });
17631
+ function VodPlaybackProvider({ children }) {
17632
+ const [request, setRequest] = (0, react$1.useState)(null);
17633
+ const value = (0, react$1.useMemo)(() => ({
17634
+ request,
17635
+ play: (req) => setRequest(req),
17636
+ clear: () => setRequest(null)
17637
+ }), [request]);
17638
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
17639
+ value,
17640
+ children
17641
+ });
17642
+ }
17643
+ /** @returns the VOD playback controller (inert no-op outside a provider). */
17644
+ function useVodPlayback() {
17645
+ return (0, react$1.useContext)(VodPlaybackContext);
17646
+ }
17647
+ //#endregion
17648
+ //#region src/composites/clips/ClipPanel.tsx
17649
+ /**
17650
+ * The selected clip — what it is, how it plays, and how it is saved.
17651
+ *
17652
+ * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
17653
+ * served }` and the panel hands it to the SAME hero the recordings timeline
17654
+ * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
17655
+ * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
17656
+ * here is the thing D549 decision 22 exists to refuse.
17657
+ *
17658
+ * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
17659
+ * fails, so the format the provider named is forwarded verbatim and never
17660
+ * defaulted here.
17661
+ *
17662
+ * **`served` is shown.** A clip that holds only one twin answers with the one
17663
+ * it has, and the surface SAYS so — a missing main twin must never be served
17664
+ * silently as if it were the asked-for quality (D549 decision 15). Both ids
17665
+ * are already on the row, so changing quality re-asks the hub and never
17666
+ * re-searches the camera.
17667
+ *
17668
+ * **Nothing is appended to the URL.** The route is `access:'authenticated'`
17669
+ * and the session cookie authenticates it (D549 decision 22); the same URL is
17670
+ * the download.
17671
+ */
17672
+ var PROFILES = [
17673
+ "low",
17674
+ "mid",
17675
+ "high"
17676
+ ];
17677
+ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
17678
+ const vod = useVodPlayback();
17679
+ const playback = useVideoclipsGetClipPlayback({
17680
+ deviceId,
17681
+ clipId: clip.id,
17682
+ profile
17683
+ });
17684
+ const data = playback.data;
17685
+ const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
17686
+ (0, react$1.useEffect)(() => {
17687
+ if (data === void 0) return;
17688
+ vod.play({
17689
+ url: hubPath(data.playbackUrl),
17690
+ ...data.format === void 0 ? {} : { format: data.format },
17691
+ label
17692
+ });
17693
+ }, [data, label]);
17694
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17695
+ className: "space-y-2 rounded-md border border-border p-3 text-sm",
17696
+ children: [
17697
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17698
+ className: "font-medium",
17699
+ children: label
17700
+ }),
17701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17702
+ className: "flex items-center gap-2",
17703
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17704
+ className: "text-xs text-muted-foreground",
17705
+ children: "Quality"
17706
+ }), PROFILES.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17707
+ type: "button",
17708
+ "aria-pressed": option === profile,
17709
+ onClick: () => onProfileChange(option),
17710
+ className: `rounded px-2 py-0.5 text-xs ${option === profile ? "bg-primary text-primary-foreground" : "bg-muted"}`,
17711
+ children: option
17712
+ }, option))]
17713
+ }),
17714
+ playback.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17715
+ role: "status",
17716
+ children: "Preparing the clip — the file is fetched and muxed once…"
17717
+ }) : playback.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17718
+ role: "alert",
17719
+ children: ["This clip could not be prepared: ", playback.error?.message ?? "no reason given"]
17720
+ }) : data === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17721
+ className: "space-y-1",
17722
+ children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17723
+ className: "text-xs text-amber-600 dark:text-amber-400",
17724
+ children: [
17725
+ "Served the ",
17726
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: data.served }),
17727
+ " stream — this clip has no ",
17728
+ profile,
17729
+ " twin."
17730
+ ]
17731
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
17732
+ href: hubPath(data.playbackUrl),
17733
+ download: true,
17734
+ className: "text-xs underline",
17735
+ children: "Download this clip"
17736
+ })]
17737
+ })
17738
+ ]
17739
+ });
17740
+ }
17741
+ //#endregion
17742
+ //#region src/composites/clips/ClipSilentSources.tsx
17743
+ function ClipSilentSources({ silent }) {
17744
+ if (silent.length === 0) return null;
17745
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
17746
+ className: "space-y-1 rounded-md border border-amber-500/40 bg-amber-500/5 p-2 text-xs",
17747
+ children: silent.map((notice) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
17748
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17749
+ className: "font-medium",
17750
+ children: notice.label
17751
+ }),
17752
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17753
+ className: "text-muted-foreground",
17754
+ children: [
17755
+ " (",
17756
+ notice.source,
17757
+ ")"
17758
+ ]
17759
+ }),
17760
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: notice.headline }),
17761
+ notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17762
+ className: "text-muted-foreground break-words",
17763
+ children: notice.detail
17764
+ })
17765
+ ] }, notice.source))
17766
+ });
17767
+ }
17768
+ //#endregion
17769
+ //#region src/composites/clips/ClipSourceRow.tsx
17770
+ var TONE_CLASS = {
17771
+ ok: "text-emerald-600 dark:text-emerald-400",
17772
+ warn: "text-amber-600 dark:text-amber-400",
17773
+ error: "text-rose-600 dark:text-rose-400"
17774
+ };
17775
+ function ClipSourceRow({ notice, catalogAge, checked, onToggle }) {
17776
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
17777
+ className: "flex items-start gap-2 py-1.5",
17778
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17779
+ type: "checkbox",
17780
+ className: "mt-1",
17781
+ checked,
17782
+ "aria-label": `Show ${notice.label}`,
17783
+ onChange: (e) => onToggle(notice.source, e.target.checked)
17784
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17785
+ className: "min-w-0",
17786
+ children: [
17787
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17788
+ className: "flex flex-wrap items-baseline gap-x-2",
17789
+ children: [
17790
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17791
+ className: "font-medium",
17792
+ children: notice.label
17793
+ }),
17794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17795
+ className: "text-xs text-muted-foreground",
17796
+ children: notice.source
17797
+ }),
17798
+ catalogAge === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17799
+ className: "text-xs text-muted-foreground",
17800
+ children: ["read ", catalogAge]
17801
+ })
17802
+ ]
17803
+ }),
17804
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17805
+ className: `text-xs ${TONE_CLASS[notice.tone]}`,
17806
+ children: notice.headline
17807
+ }),
17808
+ notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17809
+ className: "text-xs text-muted-foreground break-words",
17810
+ children: notice.detail
17811
+ })
17812
+ ]
17813
+ })]
17814
+ });
17815
+ }
17816
+ //#endregion
17817
+ //#region src/composites/clips/clip-tile-media.ts
17818
+ /**
17819
+ * Why no still exists, in words. Keyed by the provider's own vocabulary so a
17820
+ * reason this table does not know still reaches the operator verbatim.
17821
+ */
17822
+ var REFUSAL_TEXT = {
17823
+ sleeping: "The camera is asleep — a still would cost a wake",
17824
+ "camera-refused": "The camera refused the still request",
17825
+ "no-keyframe": "No decodable frame inside this window",
17826
+ unsupported: "This source cannot produce stills",
17827
+ "queue-full": "The camera is busy — its still queue is full",
17828
+ "no-catalog-row": "This clip is no longer in the catalog"
17829
+ };
17830
+ function refusal(reason) {
17831
+ return {
17832
+ kind: "unavailable",
17833
+ reason,
17834
+ text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
17835
+ };
17836
+ }
17837
+ /** What to draw for one clip's tile. */
17838
+ function resolveTileMedia(clip) {
17839
+ if (clip.thumbnail !== void 0) return {
17840
+ kind: "vouched",
17841
+ url: clip.thumbnail
17842
+ };
17843
+ if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
17844
+ if (clip.thumbnailMint !== void 0) return {
17845
+ kind: "mint",
17846
+ url: clip.thumbnailMint
17847
+ };
17848
+ return {
17849
+ kind: "none",
17850
+ text: "This clip carries no still and offered none"
17851
+ };
17852
+ }
17853
+ /**
17854
+ * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
17855
+ *
17856
+ * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
17857
+ * the offer for as long as the tile is mounted and is not retried behind the
17858
+ * operator's back. An unknown reason is shown raw rather than dropped: a
17859
+ * firmware inventing a refusal must not become a blank tile.
17860
+ */
17861
+ function latchMintRefusal(reason) {
17862
+ return refusal(reason);
17863
+ }
17864
+ //#endregion
17865
+ //#region src/composites/clips/clip-thumbnail-fetch.ts
17866
+ /**
17867
+ * Asking a `thumbnailMint` route for a still — and hearing its refusal.
17868
+ *
17869
+ * The route answers **200** with a JPEG or **204** with the reason in
17870
+ * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
17871
+ * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
17872
+ * a 204 fires `onerror` with nothing to say, turning a named refusal into an
17873
+ * anonymous broken tile. So a mint goes through `fetch`.
17874
+ *
17875
+ * **Nothing is appended to the URL.** Both data-plane routes are registered
17876
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
17877
+ * the session cookie the admin UI sets at login, so the URL is never a
17878
+ * credential (D549 decision 22) — it may sit in history or a `Referer` and
17879
+ * open nothing.
17880
+ *
17881
+ * The refusal is about NOW and is LATCHED by the caller: a camera that just
17882
+ * said "busy" is not asked again behind the operator's back.
17883
+ */
17884
+ /**
17885
+ * Ask a mint route for one still.
17886
+ *
17887
+ * Never rejects: a thrown fetch is a refusal like any other, because a tile
17888
+ * that throws is a tile that renders nothing and says nothing — the exact
17889
+ * failure this module exists to prevent.
17890
+ */
17891
+ async function fetchMintedThumbnail(url, fetcher) {
17892
+ let response;
17893
+ try {
17894
+ response = await fetcher(url, { credentials: "same-origin" });
17895
+ } catch (err) {
17896
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
17897
+ }
17898
+ if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
17899
+ if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
17900
+ try {
17901
+ return {
17902
+ kind: "image",
17903
+ blob: await response.blob()
17904
+ };
17905
+ } catch (err) {
17906
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
17907
+ }
17908
+ }
17909
+ //#endregion
17910
+ //#region src/composites/clips/ClipTile.tsx
17911
+ /**
17912
+ * One clip, as a tile.
17913
+ *
17914
+ * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
17915
+ * the reason there is none (D549 decision 5). An offer is fetched rather than
17916
+ * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
17917
+ * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
17918
+ * refusal is LATCHED for the life of the tile and never retried behind the
17919
+ * operator's back.
17920
+ *
17921
+ * The row also carries what the camera itself said: its native types, the
17922
+ * behaviours beside the class, which storages hold it, and — for a row that
17923
+ * is an EVENT sitting inside no file — `playable: false` with its reason
17924
+ * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
17925
+ * and never offered as playable-then-failing (D549 decision 19).
17926
+ */
17927
+ function clockOf(ms) {
17928
+ return new Date(ms).toLocaleTimeString(void 0, {
17929
+ hour: "2-digit",
17930
+ minute: "2-digit",
17931
+ second: "2-digit"
17932
+ });
17933
+ }
17934
+ /**
17935
+ * Ask a mint route once, and LATCH whatever it says.
17936
+ *
17937
+ * The offer is memoised on the three fields it is derived from, not rebuilt
17938
+ * per render: `resolveTileMedia` returns a fresh object every call, and an
17939
+ * effect keyed on that object re-runs on every commit — which, because the
17940
+ * effect sets state, is an infinite render loop. It was one here, and it hung
17941
+ * the suite rather than failing it.
17942
+ *
17943
+ * The refusal is latched for the life of the tile: a camera that just said
17944
+ * "busy" is not asked again behind the operator's back (D549 decision 5).
17945
+ */
17946
+ function useMintedStill(clip) {
17947
+ const offered = (0, react$1.useMemo)(() => resolveTileMedia(clip), [
17948
+ clip.thumbnail,
17949
+ clip.thumbnailMint,
17950
+ clip.thumbnailUnavailable?.reason
17951
+ ]);
17952
+ const [latched, setLatched] = (0, react$1.useState)(null);
17953
+ const mintUrl = offered.kind === "mint" ? offered.url : null;
17954
+ (0, react$1.useEffect)(() => {
17955
+ setLatched(null);
17956
+ if (mintUrl === null || typeof fetch !== "function") return void 0;
17957
+ let objectUrl = null;
17958
+ let cancelled = false;
17959
+ (async () => {
17960
+ const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
17961
+ if (cancelled) return;
17962
+ if (answer.kind === "image") {
17963
+ objectUrl = URL.createObjectURL(answer.blob);
17964
+ setLatched({
17965
+ kind: "vouched",
17966
+ url: objectUrl
17967
+ });
17968
+ return;
17969
+ }
17970
+ setLatched(answer);
17971
+ })();
17972
+ return () => {
17973
+ cancelled = true;
17974
+ if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
17975
+ };
17976
+ }, [mintUrl]);
17977
+ return latched ?? offered;
17978
+ }
17979
+ function ClipTile({ clip, selected, onSelect }) {
17980
+ const media = useMintedStill(clip);
17981
+ const playable = clip.playable !== false;
17982
+ const labels = [...clip.labels ?? [], ...clip.behaviours ?? []];
17983
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17984
+ type: "button",
17985
+ onClick: () => onSelect(clip),
17986
+ disabled: !playable,
17987
+ "aria-current": selected ? "true" : void 0,
17988
+ className: `flex w-full gap-3 rounded-md border p-2 text-left ${selected ? "border-primary" : "border-border"} ${playable ? "" : "opacity-70"}`,
17989
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17990
+ className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-muted text-[10px] text-muted-foreground",
17991
+ children: media.kind === "vouched" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
17992
+ src: media.url,
17993
+ alt: "",
17994
+ className: "h-full w-full object-cover"
17995
+ }) : media.kind === "mint" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "minting…" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17996
+ className: "px-1 text-center",
17997
+ children: media.text
17998
+ })
17999
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18000
+ className: "min-w-0 flex-1",
18001
+ children: [
18002
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18003
+ className: "font-medium",
18004
+ children: clockOf(clip.timeRange.startMs)
18005
+ }),
18006
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18007
+ className: "text-xs text-muted-foreground",
18008
+ children: clip.source
18009
+ }),
18010
+ labels.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18011
+ className: "text-xs",
18012
+ children: labels.join(" · ")
18013
+ }),
18014
+ clip.nativeTypes === void 0 || clip.nativeTypes.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18015
+ className: "text-[10px] text-muted-foreground",
18016
+ children: clip.nativeTypes.join(", ")
18017
+ }),
18018
+ playable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18019
+ className: "text-xs text-amber-600 dark:text-amber-400",
18020
+ children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
18021
+ })
18022
+ ]
18023
+ })]
18024
+ }) });
18025
+ }
18026
+ //#endregion
18027
+ //#region src/composites/clips/clip-day-window.ts
18028
+ function pad2(value) {
18029
+ return value < 10 ? `0${String(value)}` : String(value);
18030
+ }
18031
+ /** Parse `YYYY-MM-DD` into its local-midnight `Date`. */
18032
+ function startOfDay(dayId) {
18033
+ const [year, month, day] = dayId.split("-").map((part) => Number(part));
18034
+ return new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1, 0, 0, 0, 0);
18035
+ }
18036
+ /** The LOCAL calendar day an instant falls in, as `YYYY-MM-DD`. */
18037
+ function toDayId(instant) {
18038
+ return `${String(instant.getFullYear())}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
18039
+ }
18040
+ /** The half-open local-wall-clock window of one day. */
18041
+ function dayWindow$1(dayId) {
18042
+ const start = startOfDay(dayId);
18043
+ const end = startOfDay(dayId);
18044
+ end.setDate(end.getDate() + 1);
18045
+ return {
18046
+ since: start.getTime(),
18047
+ until: end.getTime()
18048
+ };
18049
+ }
18050
+ /** Walk `days` calendar days from `dayId` — DST-safe, because `Date` does it. */
18051
+ function shiftDay(dayId, days) {
18052
+ const moved = startOfDay(dayId);
18053
+ moved.setDate(moved.getDate() + days);
18054
+ return toDayId(moved);
18055
+ }
18056
+ /**
18057
+ * The day, spelled out for the header.
18058
+ *
18059
+ * An empty list has to say WHICH day it is empty for — "no clips" with no date
18060
+ * beside it is the ambiguity this surface exists to remove — so this label is
18061
+ * rendered next to every state, not only the populated one.
18062
+ */
18063
+ function formatDayLabel(dayId) {
18064
+ return startOfDay(dayId).toLocaleDateString(void 0, {
18065
+ weekday: "short",
18066
+ year: "numeric",
18067
+ month: "short",
18068
+ day: "numeric"
18069
+ });
18070
+ }
18071
+ //#endregion
18072
+ //#region src/composites/clips/clip-source-state.ts
18073
+ var STATE_COPY = {
18074
+ ok: {
18075
+ headline: "Answering",
18076
+ tone: "ok",
18077
+ answered: true
18078
+ },
18079
+ sleeping: {
18080
+ headline: "Asleep — showing the catalog read while it was awake",
18081
+ tone: "warn",
18082
+ answered: true
18083
+ },
18084
+ unreachable: {
18085
+ headline: "Could not be reached — its clips are missing from this list",
18086
+ tone: "error",
18087
+ answered: false
18088
+ },
18089
+ "no-storage": {
18090
+ headline: "No storage card — this camera keeps nothing of its own",
18091
+ tone: "warn",
18092
+ answered: false
18093
+ },
18094
+ "index-empty": {
18095
+ headline: "The camera's calendar marks these days, and its own index lists nothing",
18096
+ tone: "warn",
18097
+ answered: false
18098
+ }
18099
+ };
18100
+ /** Turn one source row into the line the picker and the list show. */
18101
+ function describeSource(source) {
18102
+ const copy = STATE_COPY[source.availability.state];
18103
+ return {
18104
+ source: source.source,
18105
+ label: source.label,
18106
+ headline: copy.headline,
18107
+ detail: source.availability.reason ?? null,
18108
+ tone: copy.tone,
18109
+ answered: copy.answered
18110
+ };
18111
+ }
18112
+ var MINUTE_MS$1 = 6e4;
18113
+ var HOUR_MS$1 = 36e5;
18114
+ var DAY_MS$2 = 864e5;
18115
+ /**
18116
+ * How old a source's catalog is, or null when it did not say.
18117
+ *
18118
+ * Null is NOT "now". A source that omits `catalogAsOf` was read live and has
18119
+ * no age to show; inventing "just now" for a source that never told us would
18120
+ * be the same lie as drawing a cached list as current (D549 decision 13).
18121
+ */
18122
+ function formatCatalogAge(catalogAsOf, now) {
18123
+ if (catalogAsOf === void 0) return null;
18124
+ const age = Math.max(0, now - catalogAsOf);
18125
+ if (age < MINUTE_MS$1) return "under a minute ago";
18126
+ if (age < HOUR_MS$1) return `${String(Math.floor(age / MINUTE_MS$1))} min ago`;
18127
+ if (age < DAY_MS$2) return `${String(Math.floor(age / HOUR_MS$1))} h ago`;
18128
+ return `${String(Math.floor(age / DAY_MS$2))} d ago`;
18129
+ }
18130
+ //#endregion
18131
+ //#region src/composites/clips/clip-view-state.ts
18132
+ /** Keep only the rows of selected sources; an absent selection keeps them all. */
18133
+ function applySelection(clips, selected) {
18134
+ if (selected === void 0) return clips;
18135
+ const allowed = new Set(selected);
18136
+ return clips.filter((clip) => allowed.has(clip.source));
18137
+ }
18138
+ function resolveClipsView(input) {
18139
+ if (input.clipsStatus === "error") return {
18140
+ kind: "failed",
18141
+ message: input.clipsError ?? "The clip list could not be read"
18142
+ };
18143
+ if (input.sourcesStatus === "error" && input.clips.length === 0) return {
18144
+ kind: "failed",
18145
+ message: input.sourcesError ?? "The source list could not be read"
18146
+ };
18147
+ if (input.clipsStatus === "pending" || input.sourcesStatus === "pending") return { kind: "loading" };
18148
+ const notices = input.sources.map(describeSource);
18149
+ const answered = notices.filter((notice) => notice.answered);
18150
+ const silent = notices.filter((notice) => !notice.answered);
18151
+ const clips = applySelection(input.clips, input.selected);
18152
+ if (clips.length === 0) return {
18153
+ kind: "empty",
18154
+ dayId: input.dayId,
18155
+ answered,
18156
+ silent,
18157
+ nobodyAnswered: answered.length === 0
18158
+ };
18159
+ return {
18160
+ kind: "listed",
18161
+ clips,
18162
+ answered,
18163
+ silent
18164
+ };
18165
+ }
18166
+ //#endregion
18167
+ //#region src/composites/clips/ClipsBrowser.tsx
18168
+ /**
18169
+ * `host/clips-browser` — a camera's clips, from EVERY source it has.
18170
+ *
18171
+ * Mounted by the framework, not by hand: `videoclips.cap.ts` declares
18172
+ * `deviceConfig.ui = { kind:'widget', widgetId:'host/clips-browser',
18173
+ * tab:'clips', topTab:true }` and `deriveDeviceConfigContribution` emits the
18174
+ * section (D14). `DeviceDetail.tsx` is not edited — placement is data, and
18175
+ * `device-config-widget-wrapped-binding.spec.ts` pins that the derive works
18176
+ * for this cap's WRAPPED binding entry, which no shipped widget cap had
18177
+ * exercised before.
18178
+ *
18179
+ * ## What it shows, and why every part of it exists
18180
+ *
18181
+ * A camera's clips live in several places at once — the analytics visit log,
18182
+ * the camera's own SD card, a hub's storage — and the operator asked for all
18183
+ * of them side by side, never one hidden behind another (D549 decision 2). So:
18184
+ *
18185
+ * - **The source list is complete, always.** Every source the camera has,
18186
+ * with its availability and the age of its catalog. Unticking one hides its
18187
+ * ROWS; the source itself never leaves the list.
18188
+ * - **Nothing is deduped.** A clip held in two storages appears twice, once
18189
+ * per source, and every row says which. That is stated as a consequence in
18190
+ * D549 so it is not "fixed" later.
18191
+ * - **Four states are tellable apart**: loading, empty-with-a-cause,
18192
+ * degraded-per-source, and each silent source in its own words. Every one
18193
+ * of them used to render as "no clips", which is the defect this whole
18194
+ * line of work is about — a camera with no SD card (640) is not quiet, and
18195
+ * a camera whose own index disagrees with its own calendar (618, 6–20
18196
+ * September) is not quiet either.
18197
+ *
18198
+ * ## What the picker WRITES — nothing
18199
+ *
18200
+ * The tick boxes are the cap's own `sources` view filter, forwarded to
18201
+ * `listClips`, and they persist nothing. The persistent authority for turning
18202
+ * a source off for a camera is `deviceManager.setWrapperActive`, whose deny
18203
+ * set is keyed by **addon id** (D556 decision 3) — and one addon serves TWO
18204
+ * sources of a hub child (`native:reolink:onboard` and `native:reolink:hub`,
18205
+ * both `provider-reolink`). Wiring a per-SOURCE tick box to a per-ADDON
18206
+ * authority would make "hide the hub's event view" silently mean "turn off
18207
+ * both Reolink sources": a switch that does something other than what it says,
18208
+ * which is worse than no switch (D62). The gap and its fix are recorded in
18209
+ * ADR 557; until a source row names its own provider, this surface offers the
18210
+ * filter it can honour and no second store.
18211
+ */
18212
+ /** Newest-first, and bounded: a busy day on 592 lists 546 clips. */
18213
+ var CLIP_PAGE_LIMIT = 500;
18214
+ function ClipsBrowser({ deviceId }) {
18215
+ const [dayId, setDayId] = (0, react$1.useState)(() => toDayId(/* @__PURE__ */ new Date()));
18216
+ const [hidden, setHidden] = (0, react$1.useState)([]);
18217
+ const [selectedClip, setSelectedClip] = (0, react$1.useState)(null);
18218
+ const [profile, setProfile] = (0, react$1.useState)("low");
18219
+ const presence = useCapabilityPresence("videoclips");
18220
+ const window = (0, react$1.useMemo)(() => dayWindow$1(dayId), [dayId]);
18221
+ const sources = useVideoclipsListSources({ deviceId });
18222
+ const clips = useVideoclipsListClips({
18223
+ deviceId,
18224
+ since: window.since,
18225
+ until: window.until,
18226
+ limit: CLIP_PAGE_LIMIT
18227
+ });
18228
+ const sourceRows = sources.data ?? [];
18229
+ const selected = sourceRows.map((row) => row.source).filter((source) => !hidden.includes(source));
18230
+ const view = resolveClipsView({
18231
+ dayId,
18232
+ clipsStatus: clips.status,
18233
+ clips: clips.data ?? [],
18234
+ sourcesStatus: sources.status,
18235
+ sources: sourceRows,
18236
+ ...hidden.length === 0 ? {} : { selected },
18237
+ ...clips.error ? { clipsError: clips.error.message } : {},
18238
+ ...sources.error ? { sourcesError: sources.error.message } : {}
18239
+ });
18240
+ const now = Date.now();
18241
+ const toggle = (source, next) => setHidden((current) => next ? current.filter((id) => id !== source) : current.includes(source) ? current : [...current, source]);
18242
+ if (presence === "pending") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18243
+ role: "status",
18244
+ children: "Asking the registry whether this cluster serves clips…"
18245
+ });
18246
+ if (presence === "unknown") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18247
+ role: "alert",
18248
+ children: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
18249
+ });
18250
+ if (presence === "absent") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18251
+ role: "status",
18252
+ children: [
18253
+ "No provider of ",
18254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "videoclips" }),
18255
+ " is installed in this cluster."
18256
+ ]
18257
+ });
18258
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18259
+ className: "space-y-4",
18260
+ children: [
18261
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18262
+ "aria-label": "Clip sources",
18263
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
18264
+ className: "text-sm font-semibold",
18265
+ children: "Sources"
18266
+ }), sources.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18267
+ role: "status",
18268
+ children: "Loading this camera's clip sources…"
18269
+ }) : sourceRows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18270
+ role: "status",
18271
+ children: "No provider answered for this camera — nothing claims to hold clips for it."
18272
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", { children: sourceRows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSourceRow, {
18273
+ notice: describeSource(row),
18274
+ catalogAge: formatCatalogAge(row.availability.catalogAsOf, now),
18275
+ checked: !hidden.includes(row.source),
18276
+ onToggle: toggle
18277
+ }, row.source)) })]
18278
+ }),
18279
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18280
+ "aria-label": "Day",
18281
+ className: "flex items-center gap-2",
18282
+ children: [
18283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18284
+ type: "button",
18285
+ "aria-label": "Previous day",
18286
+ onClick: () => setDayId(shiftDay(dayId, -1)),
18287
+ children: "‹"
18288
+ }),
18289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18290
+ className: "font-medium",
18291
+ children: formatDayLabel(dayId)
18292
+ }),
18293
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18294
+ type: "button",
18295
+ "aria-label": "Next day",
18296
+ onClick: () => setDayId(shiftDay(dayId, 1)),
18297
+ children: "›"
18298
+ }),
18299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18300
+ type: "button",
18301
+ onClick: () => setDayId(toDayId(/* @__PURE__ */ new Date())),
18302
+ children: "Today"
18303
+ })
18304
+ ]
18305
+ }),
18306
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
18307
+ "aria-label": "Clips",
18308
+ children: view.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18309
+ role: "status",
18310
+ children: [
18311
+ "Loading clips for ",
18312
+ formatDayLabel(dayId),
18313
+ "…"
18314
+ ]
18315
+ }) : view.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18316
+ role: "alert",
18317
+ children: ["The clip list could not be read: ", view.message]
18318
+ }) : view.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18319
+ className: "space-y-2",
18320
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18321
+ role: "status",
18322
+ children: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}.` : `No clips on ${formatDayLabel(dayId)} from ${view.answered.map((notice) => notice.label).join(", ")}.`
18323
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSilentSources, { silent: view.silent })]
18324
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18325
+ className: "space-y-2",
18326
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSilentSources, { silent: view.silent }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
18327
+ className: "space-y-2",
18328
+ children: view.clips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
18329
+ clip,
18330
+ selected: selectedClip?.id === clip.id,
18331
+ onSelect: setSelectedClip
18332
+ }, `${clip.source}:${clip.id}`))
18333
+ })]
18334
+ })
18335
+ }),
18336
+ selectedClip === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipPanel, {
18337
+ deviceId,
18338
+ clip: selectedClip,
18339
+ profile,
18340
+ onProfileChange: setProfile
18341
+ })
18342
+ ]
18343
+ });
18344
+ }
18345
+ //#endregion
17576
18346
  //#region src/hooks/use-navigation.ts
17577
18347
  /**
17578
18348
  * useNavigation — drive a `navigation` robot camera from the UI.
@@ -19356,46 +20126,6 @@ function RecordingSettings({ initial, availableProfiles, saving, onSave, sensorC
19356
20126
  });
19357
20127
  }
19358
20128
  //#endregion
19359
- //#region src/contexts/vod-playback.tsx
19360
- /**
19361
- * VodPlayback — shared context coordinating recorded (VOD) playback between a
19362
- * widget/section (the Recording panel) and the hero StreamPanel.
19363
- *
19364
- * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
19365
- * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
19366
- * through the hub data-plane) instead of the live WebRTC stream. `clear()`
19367
- * returns the hero to live.
19368
- *
19369
- * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
19370
- * the Module-Federation boundary: an addon-shipped widget consuming
19371
- * `useVodPlayback()` resolves the SAME provider instance the host mounted —
19372
- * the same mechanism `player-overlays` / `zone-editing` use to let federated
19373
- * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
19374
- * outside a provider get the inert default (no-op), so a StreamPanel mounted
19375
- * on a page without the Recording widget stays live-only without crashing.
19376
- */
19377
- var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
19378
- request: null,
19379
- play: () => {},
19380
- clear: () => {}
19381
- });
19382
- function VodPlaybackProvider({ children }) {
19383
- const [request, setRequest] = (0, react$1.useState)(null);
19384
- const value = (0, react$1.useMemo)(() => ({
19385
- request,
19386
- play: (req) => setRequest(req),
19387
- clear: () => setRequest(null)
19388
- }), [request]);
19389
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
19390
- value,
19391
- children
19392
- });
19393
- }
19394
- /** @returns the VOD playback controller (inert no-op outside a provider). */
19395
- function useVodPlayback() {
19396
- return (0, react$1.useContext)(VodPlaybackContext);
19397
- }
19398
- //#endregion
19399
20129
  //#region src/lib/recorded-control-protocol.ts
19400
20130
  /** Serialize a command for `RTCDataChannel.send` (matches the server schema). */
19401
20131
  function serializeRecordedCommand(cmd) {
@@ -20990,7 +21720,8 @@ var HOST_WIDGETS = {
20990
21720
  "host/consumables-panel": ConsumablesPanel,
20991
21721
  "host/ptz-autotrack": AutotrackSection,
20992
21722
  "host/recording-panel": RecordingPanel,
20993
- "host/camera-grid-layout": CameraGridLayoutEditor
21723
+ "host/camera-grid-layout": CameraGridLayoutEditor,
21724
+ "host/clips-browser": ClipsBrowser
20994
21725
  };
20995
21726
  //#endregion
20996
21727
  //#region src/composites/widget-slot.tsx
@@ -48901,7 +49632,7 @@ function hlsLevelToInfo(level) {
48901
49632
  name: level.name.length > 0 ? level.name : void 0
48902
49633
  };
48903
49634
  }
48904
- function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQualityOptions, selectedLevel = -1 }) {
49635
+ function HlsVideo({ url, format = "hls", authToken, posterUrl, autoPlay = true, className, onQualityOptions, selectedLevel = -1 }) {
48905
49636
  const videoRef = (0, react$1.useRef)(null);
48906
49637
  const hlsRef = (0, react$1.useRef)(null);
48907
49638
  const [error, setError] = (0, react$1.useState)(null);
@@ -48914,6 +49645,23 @@ function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQua
48914
49645
  onQualityOptions?.([]);
48915
49646
  const onPlaying = () => setLoading(false);
48916
49647
  video.addEventListener("playing", onPlaying);
49648
+ if (format === "mp4") {
49649
+ video.src = url;
49650
+ const onLoaded = () => {
49651
+ if (autoPlay) video.play().catch(() => {});
49652
+ };
49653
+ const onFailed = () => setError("Could not load this clip.");
49654
+ video.addEventListener("loadedmetadata", onLoaded);
49655
+ video.addEventListener("error", onFailed);
49656
+ onQualityOptions?.([]);
49657
+ return () => {
49658
+ video.removeEventListener("loadedmetadata", onLoaded);
49659
+ video.removeEventListener("error", onFailed);
49660
+ video.removeEventListener("playing", onPlaying);
49661
+ video.removeAttribute("src");
49662
+ video.load();
49663
+ };
49664
+ }
48917
49665
  if (!("MediaSource" in window) && video.canPlayType("application/vnd.apple.mpegurl")) {
48918
49666
  video.src = url;
48919
49667
  const onLoaded = () => {
@@ -48976,6 +49724,7 @@ function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQua
48976
49724
  };
48977
49725
  }, [
48978
49726
  url,
49727
+ format,
48979
49728
  authToken,
48980
49729
  autoPlay,
48981
49730
  onQualityOptions
@@ -51119,7 +51868,7 @@ function ImageOffIcon({ className }) {
51119
51868
  ]
51120
51869
  });
51121
51870
  }
51122
- function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, playbackUrl, playbackAuthToken, onExitPlayback, posterUrl, streams, activeStreamId: controlledStreamId, onStreamChange, deviceName, phase, pipelineMetrics, detections, stationaryObjects, defaultShowDetections = true, defaultShowMotion = false, streamStats, autoPlay = false, showPlayStop = false, snapshotSrc, snapshotLoading = false, onRefreshSnapshot, showStreamStats = false, children, extraOverlay, ptzAvailable = false, ptzShown = false, ptzOverlay, onPtzToggle, intercomAvailable = false, intercomShown = false, onIntercomToggle, chromeless = false, className }) {
51871
+ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, playbackUrl, playbackFormat, playbackAuthToken, onExitPlayback, posterUrl, streams, activeStreamId: controlledStreamId, onStreamChange, deviceName, phase, pipelineMetrics, detections, stationaryObjects, defaultShowDetections = true, defaultShowMotion = false, streamStats, autoPlay = false, showPlayStop = false, snapshotSrc, snapshotLoading = false, onRefreshSnapshot, showStreamStats = false, children, extraOverlay, ptzAvailable = false, ptzShown = false, ptzOverlay, onPtzToggle, intercomAvailable = false, intercomShown = false, onIntercomToggle, chromeless = false, className }) {
51123
51872
  const registeredButtons = usePlayerToolbarButtons();
51124
51873
  const recordedPlayback = useRecordedPlayback();
51125
51874
  const [isPlaying, setIsPlaying] = (0, react$1.useState)(autoPlay);
@@ -51420,6 +52169,7 @@ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIce
51420
52169
  className: "aspect-video relative",
51421
52170
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(HlsVideo, {
51422
52171
  url: playbackUrl,
52172
+ ...playbackFormat ? { format: playbackFormat } : {},
51423
52173
  authToken: playbackAuthToken ?? null,
51424
52174
  onQualityOptions: handleVodQualityOptions,
51425
52175
  selectedLevel: vodLevel,
@@ -52796,6 +53546,7 @@ exports.badgeFor$1 = badgeFor;
52796
53546
  exports.buildStepTreeFromSchema = buildStepTreeFromSchema;
52797
53547
  exports.childEntityId = childEntityId;
52798
53548
  exports.childListName = childListName;
53549
+ exports.classifyCapability = classifyCapability;
52799
53550
  exports.cn = cn;
52800
53551
  exports.columnsForContext = columnsForContext;
52801
53552
  exports.containerChildToRef = containerChildToRef;
@@ -53034,6 +53785,7 @@ exports.useCameraStreamsGetCameraStreams = useCameraStreamsGetCameraStreams;
53034
53785
  exports.useCameraStreamsGetProfileRtspEntries = useCameraStreamsGetProfileRtspEntries;
53035
53786
  exports.useCameraStreamsGetRtspEntries = useCameraStreamsGetRtspEntries;
53036
53787
  exports.useCameraStreamsPickStream = useCameraStreamsPickStream;
53788
+ exports.useCapabilityPresence = useCapabilityPresence;
53037
53789
  exports.useCarbonMonoxideGetStatus = useCarbonMonoxideGetStatus;
53038
53790
  exports.useClimateControlGetStatus = useClimateControlGetStatus;
53039
53791
  exports.useClimateControlSetFanMode = useClimateControlSetFanMode;
@@ -53987,6 +54739,7 @@ exports.useValveStop = useValveStop;
53987
54739
  exports.useVibrationGetStatus = useVibrationGetStatus;
53988
54740
  exports.useVideoclipsGetClipPlayback = useVideoclipsGetClipPlayback;
53989
54741
  exports.useVideoclipsListClips = useVideoclipsListClips;
54742
+ exports.useVideoclipsListSources = useVideoclipsListSources;
53990
54743
  exports.useVodPlayback = useVodPlayback;
53991
54744
  exports.useWaterHeaterGetStatus = useWaterHeaterGetStatus;
53992
54745
  exports.useWaterHeaterSetAway = useWaterHeaterSetAway;