@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/composites/clips/ClipPanel.d.ts +8 -0
- package/dist/composites/clips/ClipSilentSources.d.ts +4 -0
- package/dist/composites/clips/ClipSourceRow.d.ts +9 -0
- package/dist/composites/clips/ClipTile.d.ts +7 -0
- package/dist/composites/clips/ClipsBrowser.d.ts +4 -0
- package/dist/composites/clips/clip-day-window.d.ts +37 -0
- package/dist/composites/clips/clip-source-state.d.ts +34 -0
- package/dist/composites/clips/clip-thumbnail-fetch.d.ts +16 -0
- package/dist/composites/clips/clip-tile-media.d.ts +27 -0
- package/dist/composites/clips/clip-view-state.d.ts +34 -0
- package/dist/composites/hls-video.d.ts +17 -2
- package/dist/composites/stream-panel.d.ts +8 -1
- package/dist/contexts/vod-playback.d.ts +13 -1
- package/dist/generated/system-hooks.d.ts +2 -0
- package/dist/hooks/use-capability-presence.d.ts +32 -0
- package/dist/index.cjs +796 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +794 -44
- package/package.json +1 -1
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;
|