@camstack/ui-library 0.1.52 → 0.1.54

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
@@ -37,6 +37,10 @@ let _trpc_client = require("@trpc/client");
37
37
  _trpc_client = __toESM(_trpc_client, 1);
38
38
  let _trpc_react_query = require("@trpc/react-query");
39
39
  _trpc_react_query = __toESM(_trpc_react_query, 1);
40
+ let react_konva = require("react-konva");
41
+ react_konva = __toESM(react_konva, 1);
42
+ let konva = require("konva");
43
+ konva = __toESM(konva, 1);
40
44
  let _camstack_types = require("@camstack/types");
41
45
  let _camstack_sdk = require("@camstack/sdk");
42
46
  let zod = require("zod");
@@ -6875,6 +6879,8 @@ function populateShareCache() {
6875
6879
  cache.share["@tanstack/react-query"] ??= _tanstack_react_query;
6876
6880
  cache.share["@trpc/client"] ??= _trpc_client;
6877
6881
  cache.share["@trpc/react-query"] ??= _trpc_react_query;
6882
+ cache.share["react-konva"] ??= react_konva;
6883
+ cache.share["konva"] ??= konva;
6878
6884
  cache.share["@camstack/ui-library"] ??= readHostGlobal("__camstackUiLibrary");
6879
6885
  cache.share["@camstack/sdk"] ??= readHostGlobal("__camstackSdk");
6880
6886
  cache.share["@camstack/types"] ??= readHostGlobal("__camstackTypes");
@@ -6893,6 +6899,8 @@ function ensureMfHostInit() {
6893
6899
  "@tanstack/react-query": npmShared(() => _tanstack_react_query),
6894
6900
  "@trpc/client": npmShared(() => _trpc_client),
6895
6901
  "@trpc/react-query": npmShared(() => _trpc_react_query),
6902
+ "react-konva": npmShared(() => react_konva),
6903
+ "konva": npmShared(() => konva),
6896
6904
  "@camstack/ui-library": npmShared(() => readHostGlobal("__camstackUiLibrary")),
6897
6905
  "@camstack/sdk": npmShared(() => readHostGlobal("__camstackSdk")),
6898
6906
  "@camstack/types": npmShared(() => readHostGlobal("__camstackTypes"))
@@ -15948,6 +15956,12 @@ var useDecoderPushPacket = trpc.decoder.pushPacket.useQuery;
15948
15956
  var useDecoderOpenStream = trpc.decoder.openStream.useQuery;
15949
15957
  /** Generated alias around `trpc.decoder.pullFrames.useQuery`. */
15950
15958
  var useDecoderPullFrames = trpc.decoder.pullFrames.useQuery;
15959
+ /** Generated alias around `trpc.decoder.pullHandles.useQuery`. */
15960
+ var useDecoderPullHandles = trpc.decoder.pullHandles.useQuery;
15961
+ /** Generated alias around `trpc.decoder.getFrame.useQuery`. */
15962
+ var useDecoderGetFrame = trpc.decoder.getFrame.useQuery;
15963
+ /** Generated alias around `trpc.decoder.getShmStats.useQuery`. */
15964
+ var useDecoderGetShmStats = trpc.decoder.getShmStats.useQuery;
15951
15965
  /** Generated alias around `trpc.decoder.updateConfig.useQuery`. */
15952
15966
  var useDecoderUpdateConfig = trpc.decoder.updateConfig.useQuery;
15953
15967
  /** Generated alias around `trpc.decoder.getStats.useQuery`. */
@@ -16232,6 +16246,12 @@ var useMotionDetectionApplyDeviceSettingsPatch = trpc.motionDetection.applyDevic
16232
16246
  var useMotionTriggerSetMotionTrigger = trpc.motionTrigger.setMotionTrigger.useMutation;
16233
16247
  /** Generated alias around `trpc.motionTrigger.getStatus.useQuery`. */
16234
16248
  var useMotionTriggerGetStatus = trpc.motionTrigger.getStatus.useQuery;
16249
+ /** Generated alias around `trpc.motionZones.getOptions.useQuery`. */
16250
+ var useMotionZonesGetOptions = trpc.motionZones.getOptions.useQuery;
16251
+ /** Generated alias around `trpc.motionZones.setZone.useMutation`. */
16252
+ var useMotionZonesSetZone = trpc.motionZones.setZone.useMutation;
16253
+ /** Generated alias around `trpc.motionZones.getStatus.useQuery`. */
16254
+ var useMotionZonesGetStatus = trpc.motionZones.getStatus.useQuery;
16235
16255
  /** Generated alias around `trpc.mqttBroker.listBrokers.useQuery`. */
16236
16256
  var useMqttBrokerListBrokers = trpc.mqttBroker.listBrokers.useQuery;
16237
16257
  /** Generated alias around `trpc.mqttBroker.getBrokerConfig.useQuery`. */
@@ -16284,6 +16304,8 @@ var useNodesShutdownNode = trpc.nodes.shutdownNode.useMutation;
16284
16304
  var useNodesRenameNode = trpc.nodes.renameNode.useMutation;
16285
16305
  /** Generated alias around `trpc.nodes.clusterAddonStatus.useQuery`. */
16286
16306
  var useNodesClusterAddonStatus = trpc.nodes.clusterAddonStatus.useQuery;
16307
+ /** Generated alias around `trpc.nodes.getCapUsageGraph.useQuery`. */
16308
+ var useNodesGetCapUsageGraph = trpc.nodes.getCapUsageGraph.useQuery;
16287
16309
  /** Generated alias around `trpc.nodes.getNodeAddons.useQuery`. */
16288
16310
  var useNodesGetNodeAddons = trpc.nodes.getNodeAddons.useQuery;
16289
16311
  /** Generated alias around `trpc.nodes.setProcessLogLevel.useMutation`. */
@@ -16502,10 +16524,18 @@ var usePtzStop = trpc.ptz.stop.useMutation;
16502
16524
  var usePtzGetPresets = trpc.ptz.getPresets.useQuery;
16503
16525
  /** Generated alias around `trpc.ptz.goToPreset.useMutation`. */
16504
16526
  var usePtzGoToPreset = trpc.ptz.goToPreset.useMutation;
16527
+ /** Generated alias around `trpc.ptz.savePreset.useMutation`. */
16528
+ var usePtzSavePreset = trpc.ptz.savePreset.useMutation;
16529
+ /** Generated alias around `trpc.ptz.deletePreset.useMutation`. */
16530
+ var usePtzDeletePreset = trpc.ptz.deletePreset.useMutation;
16531
+ /** Generated alias around `trpc.ptz.getOptions.useQuery`. */
16532
+ var usePtzGetOptions = trpc.ptz.getOptions.useQuery;
16505
16533
  /** Generated alias around `trpc.ptz.goHome.useMutation`. */
16506
16534
  var usePtzGoHome = trpc.ptz.goHome.useMutation;
16507
16535
  /** Generated alias around `trpc.ptz.getPosition.useQuery`. */
16508
16536
  var usePtzGetPosition = trpc.ptz.getPosition.useQuery;
16537
+ /** Generated alias around `trpc.ptz.setAutofocus.useMutation`. */
16538
+ var usePtzSetAutofocus = trpc.ptz.setAutofocus.useMutation;
16509
16539
  /** Generated alias around `trpc.ptz.getStatus.useQuery`. */
16510
16540
  var usePtzGetStatus = trpc.ptz.getStatus.useQuery;
16511
16541
  /** Generated alias around `trpc.ptzAutotrack.getStatus.useQuery`. */
@@ -16662,8 +16692,18 @@ var useStreamBrokerGetStreamUrl = trpc.streamBroker.getStreamUrl.useQuery;
16662
16692
  var useStreamBrokerGetStreamWithCodec = trpc.streamBroker.getStreamWithCodec.useMutation;
16663
16693
  /** Generated alias around `trpc.streamBroker.releaseStreamWithCodec.useMutation`. */
16664
16694
  var useStreamBrokerReleaseStreamWithCodec = trpc.streamBroker.releaseStreamWithCodec.useMutation;
16665
- /** Generated alias around `trpc.streamBroker.getBroker.useQuery`. */
16666
- var useStreamBrokerGetBroker = trpc.streamBroker.getBroker.useQuery;
16695
+ /** Generated alias around `trpc.streamBroker.subscribeAudioChunks.useMutation`. */
16696
+ var useStreamBrokerSubscribeAudioChunks = trpc.streamBroker.subscribeAudioChunks.useMutation;
16697
+ /** Generated alias around `trpc.streamBroker.pullAudioChunks.useQuery`. */
16698
+ var useStreamBrokerPullAudioChunks = trpc.streamBroker.pullAudioChunks.useQuery;
16699
+ /** Generated alias around `trpc.streamBroker.unsubscribeAudioChunks.useMutation`. */
16700
+ var useStreamBrokerUnsubscribeAudioChunks = trpc.streamBroker.unsubscribeAudioChunks.useMutation;
16701
+ /** Generated alias around `trpc.streamBroker.subscribeFrames.useMutation`. */
16702
+ var useStreamBrokerSubscribeFrames = trpc.streamBroker.subscribeFrames.useMutation;
16703
+ /** Generated alias around `trpc.streamBroker.pullFrameHandles.useQuery`. */
16704
+ var useStreamBrokerPullFrameHandles = trpc.streamBroker.pullFrameHandles.useQuery;
16705
+ /** Generated alias around `trpc.streamBroker.unsubscribeFrames.useMutation`. */
16706
+ var useStreamBrokerUnsubscribeFrames = trpc.streamBroker.unsubscribeFrames.useMutation;
16667
16707
  /** Generated alias around `trpc.streamBroker.setPreBufferDuration.useMutation`. */
16668
16708
  var useStreamBrokerSetPreBufferDuration = trpc.streamBroker.setPreBufferDuration.useMutation;
16669
16709
  /** Generated alias around `trpc.streamBroker.getPreBufferInfo.useQuery`. */
@@ -16686,6 +16726,14 @@ var useStreamBrokerGetDeviceSettingsContribution = trpc.streamBroker.getDeviceSe
16686
16726
  var useStreamBrokerGetDeviceLiveContribution = trpc.streamBroker.getDeviceLiveContribution.useQuery;
16687
16727
  /** Generated alias around `trpc.streamBroker.applyDeviceSettingsPatch.useMutation`. */
16688
16728
  var useStreamBrokerApplyDeviceSettingsPatch = trpc.streamBroker.applyDeviceSettingsPatch.useMutation;
16729
+ /** Generated alias around `trpc.streamParams.getOptions.useQuery`. */
16730
+ var useStreamParamsGetOptions = trpc.streamParams.getOptions.useQuery;
16731
+ /** Generated alias around `trpc.streamParams.setProfile.useMutation`. */
16732
+ var useStreamParamsSetProfile = trpc.streamParams.setProfile.useMutation;
16733
+ /** Generated alias around `trpc.streamParams.getConfigSchema.useQuery`. */
16734
+ var useStreamParamsGetConfigSchema = trpc.streamParams.getConfigSchema.useQuery;
16735
+ /** Generated alias around `trpc.streamParams.getStatus.useQuery`. */
16736
+ var useStreamParamsGetStatus = trpc.streamParams.getStatus.useQuery;
16689
16737
  /** Generated alias around `trpc.switch.setState.useMutation`. */
16690
16738
  var useSwitchSetState = trpc.switch.setState.useMutation;
16691
16739
  /** Generated alias around `trpc.switch.getStatus.useQuery`. */
@@ -16748,6 +16796,18 @@ var useUserManagementDisableTotp = trpc.userManagement.disableTotp.useMutation;
16748
16796
  var useUserManagementGetTotpStatus = trpc.userManagement.getTotpStatus.useQuery;
16749
16797
  /** Generated alias around `trpc.userManagement.verifyTotp.useMutation`. */
16750
16798
  var useUserManagementVerifyTotp = trpc.userManagement.verifyTotp.useMutation;
16799
+ /** Generated alias around `trpc.userManagement.oauthIssueCode.useMutation`. */
16800
+ var useUserManagementOauthIssueCode = trpc.userManagement.oauthIssueCode.useMutation;
16801
+ /** Generated alias around `trpc.userManagement.oauthExchangeCode.useMutation`. */
16802
+ var useUserManagementOauthExchangeCode = trpc.userManagement.oauthExchangeCode.useMutation;
16803
+ /** Generated alias around `trpc.userManagement.oauthRefresh.useMutation`. */
16804
+ var useUserManagementOauthRefresh = trpc.userManagement.oauthRefresh.useMutation;
16805
+ /** Generated alias around `trpc.userManagement.oauthVerifyAccessToken.useQuery`. */
16806
+ var useUserManagementOauthVerifyAccessToken = trpc.userManagement.oauthVerifyAccessToken.useQuery;
16807
+ /** Generated alias around `trpc.userManagement.listOauthSessions.useQuery`. */
16808
+ var useUserManagementListOauthSessions = trpc.userManagement.listOauthSessions.useQuery;
16809
+ /** Generated alias around `trpc.userManagement.revokeOauthSession.useMutation`. */
16810
+ var useUserManagementRevokeOauthSession = trpc.userManagement.revokeOauthSession.useMutation;
16751
16811
  /** Generated alias around `trpc.webrtcSession.listStreams.useQuery`. */
16752
16812
  var useWebrtcSessionListStreams = trpc.webrtcSession.listStreams.useQuery;
16753
16813
  /** Generated alias around `trpc.webrtcSession.createSession.useMutation`. */
@@ -16791,8 +16851,16 @@ var useZonesUpdateZone = trpc.zones.updateZone.useMutation;
16791
16851
  * `vite.widgets.config.ts`). At runtime we fetch the public list of
16792
16852
  * addon-contributed widgets via `useAddonWidgetsListWidgets()`, register
16793
16853
  * each remote (`registerRemotes`) once, then resolve a widget by
16794
- * loading the exposed `./widgets` module (`loadRemote`) whose default
16795
- * export is a `Record<stableId, ComponentType<WidgetProps>>` map.
16854
+ * loading the exposed module (`loadRemote`) whose default export is a
16855
+ * `Record<componentKey, ComponentType>` map.
16856
+ *
16857
+ * Unified UI-contribution model (Task 10) — a widget declaration IS a
16858
+ * `UiContribution` with `kind:'remote'`. Both the device-detail path
16859
+ * (`ContributionRenderer`) and the dashboard render widgets through the
16860
+ * same `loadRemoteBundle` MF path. `loadRemoteBundle` is exported and
16861
+ * generic over `(remoteName, exposedModule, entryUrl)` so any
16862
+ * `UiContributionRemote` descriptor resolves without an aggregator-keyed
16863
+ * lookup.
16796
16864
  *
16797
16865
  * Why MF instead of raw ESM:
16798
16866
  * - automatic dedup of shared deps (react/react-dom/@tanstack/etc.)
@@ -16802,9 +16870,7 @@ var useZonesUpdateZone = trpc.zones.updateZone.useMutation;
16802
16870
  * wins by default).
16803
16871
  * - cross-bundle context invariant preserved automatically: every
16804
16872
  * remote's `@camstack/ui-library` import resolves to the host's
16805
- * instance, so `createContext()` references match across bundles
16806
- * (the duplicate-Context bug that motivated `createSharedContext`
16807
- * falls out for free).
16873
+ * instance, so `createContext()` references match across bundles.
16808
16874
  *
16809
16875
  * Live-update — listens to `addon.widget-ready` so newly-loaded addons
16810
16876
  * surface their widgets without a manual page reload (the aggregator
@@ -16815,11 +16881,11 @@ var useZonesUpdateZone = trpc.zones.updateZone.useMutation;
16815
16881
  */
16816
16882
  var WidgetRegistryContext = createSharedContext("camstack:widget-registry", null);
16817
16883
  /**
16818
- * Process-global cache for resolved widget bundles, keyed by
16819
- * `remoteName` (every remote exposes a single `./widgets` module whose
16820
- * default export is the `Record<stableId, Component>` map). Survives
16821
- * provider remount — avoids a full re-fetch + re-init of the MF remote
16822
- * when admin-ui's tree cycles.
16884
+ * Process-global cache for resolved remote bundles, keyed by
16885
+ * `<remoteName>::<exposedModule>`. Each cached value is the exposed
16886
+ * module's default-record map (`Record<componentKey, Component>`).
16887
+ * Survives provider remount — avoids a full re-fetch + re-init of the
16888
+ * MF remote when admin-ui's tree cycles.
16823
16889
  */
16824
16890
  var bundleModuleCache = /* @__PURE__ */ new Map();
16825
16891
  var bundleInflight = /* @__PURE__ */ new Map();
@@ -16831,6 +16897,10 @@ var bundleInflight = /* @__PURE__ */ new Map();
16831
16897
  * update.
16832
16898
  */
16833
16899
  var registeredRemotes = /* @__PURE__ */ new Set();
16900
+ /** Cache key for `bundleModuleCache` — one entry per `(remote, module)`. */
16901
+ function bundleCacheKey(remoteName, exposedModule) {
16902
+ return `${remoteName}::${exposedModule}`;
16903
+ }
16834
16904
  function isRemoteWidgetsModule(value) {
16835
16905
  if (!value || typeof value !== "object") return false;
16836
16906
  const def = value.default;
@@ -16839,8 +16909,7 @@ function isRemoteWidgetsModule(value) {
16839
16909
  /**
16840
16910
  * Diagnostic-only string for the "Got: …" tail in the not-a-record
16841
16911
  * error. Returns a JSON-friendly snapshot of the module's keys + proto
16842
- * name without exposing the value itself (the value is the unknown
16843
- * thing the module factory produced, may be huge or contain PII).
16912
+ * name without exposing the value itself.
16844
16913
  */
16845
16914
  function describeRemoteShape(mod) {
16846
16915
  if (!mod || typeof mod !== "object") return typeof mod;
@@ -16854,13 +16923,19 @@ function describeRemoteShape(mod) {
16854
16923
  };
16855
16924
  }
16856
16925
  /**
16857
- * Register the MF remote (idempotent) and load its `./widgets` module.
16858
- * Returns the resolved `Record<stableId, Component>` map.
16926
+ * Register the MF remote (idempotent) and load one of its exposed
16927
+ * modules. Returns the resolved `Record<componentKey, Component>` map
16928
+ * (the exposed module's `default` export).
16929
+ *
16930
+ * Exported so the unified `ContributionRenderer` `kind:'remote'` path
16931
+ * can load an arbitrary `{ remoteName, exposedModule }` descriptor —
16932
+ * the same path the `WidgetRegistry` uses internally for widgets.
16859
16933
  */
16860
- async function loadRemoteBundle(remoteName, entryUrl) {
16861
- const cached = bundleModuleCache.get(remoteName);
16934
+ async function loadRemoteBundle(remoteName, exposedModule, entryUrl) {
16935
+ const cacheKey = bundleCacheKey(remoteName, exposedModule);
16936
+ const cached = bundleModuleCache.get(cacheKey);
16862
16937
  if (cached) return cached;
16863
- const inflight = bundleInflight.get(remoteName);
16938
+ const inflight = bundleInflight.get(cacheKey);
16864
16939
  if (inflight) return inflight;
16865
16940
  if (!registeredRemotes.has(remoteName)) {
16866
16941
  ensureMfHostInit();
@@ -16871,22 +16946,26 @@ async function loadRemoteBundle(remoteName, entryUrl) {
16871
16946
  }], { force: false });
16872
16947
  registeredRemotes.add(remoteName);
16873
16948
  }
16874
- const promise = loadRemote(`${remoteName}/widgets`).then((mod) => {
16949
+ const promise = loadRemote(`${remoteName}/${exposedModule.startsWith("./") ? exposedModule.slice(2) : exposedModule}`).then((mod) => {
16875
16950
  if (!isRemoteWidgetsModule(mod)) {
16876
16951
  const shape = describeRemoteShape(mod);
16877
- throw new Error(`Widget remote ${remoteName} (${entryUrl}) does not expose a default record on './widgets'. Got: ${JSON.stringify(shape)}`);
16952
+ throw new Error(`Remote ${remoteName} (${entryUrl}) does not expose a default record on '${exposedModule}'. Got: ${JSON.stringify(shape)}`);
16878
16953
  }
16879
16954
  const map = mod.default;
16880
- bundleModuleCache.set(remoteName, map);
16881
- bundleInflight.delete(remoteName);
16955
+ bundleModuleCache.set(cacheKey, map);
16956
+ bundleInflight.delete(cacheKey);
16882
16957
  return map;
16883
16958
  }).catch((err) => {
16884
- bundleInflight.delete(remoteName);
16959
+ bundleInflight.delete(cacheKey);
16885
16960
  throw err;
16886
16961
  });
16887
- bundleInflight.set(remoteName, promise);
16962
+ bundleInflight.set(cacheKey, promise);
16888
16963
  return promise;
16889
16964
  }
16965
+ /** Synchronous peek at the resolved-bundle cache — `undefined` if not yet loaded. */
16966
+ function peekBundle(remoteName, exposedModule) {
16967
+ return bundleModuleCache.get(bundleCacheKey(remoteName, exposedModule));
16968
+ }
16890
16969
  var BOOT_WINDOW_MS = 3e4;
16891
16970
  var POLL_INTERVAL_MS = 2e3;
16892
16971
  function WidgetRegistryProvider({ children }) {
@@ -16906,21 +16985,24 @@ function WidgetRegistryProvider({ children }) {
16906
16985
  queryClient.invalidateQueries({ queryKey: [["addonWidgets", "listWidgets"]] });
16907
16986
  });
16908
16987
  const [resolvedTick, setResolvedTick] = (0, react$1.useState)(0);
16988
+ const widgets = (0, react$1.useMemo)(() => rawWidgets ?? [], [rawWidgets]);
16909
16989
  (0, react$1.useEffect)(() => {
16910
- if (!rawWidgets) return;
16990
+ if (widgets.length === 0) return;
16911
16991
  let cancelled = false;
16912
- const seenRemotes = /* @__PURE__ */ new Set();
16913
- for (const w of rawWidgets) {
16914
- if (seenRemotes.has(w.remoteName)) continue;
16915
- seenRemotes.add(w.remoteName);
16916
- if (bundleModuleCache.has(w.remoteName)) continue;
16992
+ const seen = /* @__PURE__ */ new Set();
16993
+ for (const w of widgets) {
16994
+ const key = bundleCacheKey(w.remote.remoteName, w.remote.exposedModule);
16995
+ if (seen.has(key)) continue;
16996
+ seen.add(key);
16997
+ if (bundleModuleCache.has(key)) continue;
16917
16998
  const entryUrl = w.bundleUrl;
16918
- loadRemoteBundle(w.remoteName, entryUrl).then(() => {
16999
+ loadRemoteBundle(w.remote.remoteName, w.remote.exposedModule, entryUrl).then(() => {
16919
17000
  if (!cancelled) setResolvedTick((t) => t + 1);
16920
17001
  }).catch((err) => {
16921
17002
  const reason = err instanceof Error ? err.message : String(err);
16922
17003
  (typeof globalThis !== "undefined" ? globalThis.console : void 0)?.error?.("[WidgetRegistry] Failed to load widget remote", {
16923
- remoteName: w.remoteName,
17004
+ remoteName: w.remote.remoteName,
17005
+ exposedModule: w.remote.exposedModule,
16924
17006
  entryUrl,
16925
17007
  reason
16926
17008
  });
@@ -16929,19 +17011,26 @@ function WidgetRegistryProvider({ children }) {
16929
17011
  return () => {
16930
17012
  cancelled = true;
16931
17013
  };
16932
- }, [rawWidgets]);
17014
+ }, [widgets]);
16933
17015
  const registry = (0, react$1.useMemo)(() => {
16934
- const widgets = rawWidgets ?? [];
16935
17016
  const byId = /* @__PURE__ */ new Map();
16936
- for (const w of widgets) byId.set(`${w.addonId}/${w.stableId}`, w);
17017
+ const entryUrlByRemote = /* @__PURE__ */ new Map();
17018
+ for (const w of widgets) {
17019
+ byId.set(`${w.addonId}/${w.stableId}`, w);
17020
+ entryUrlByRemote.set(w.remote.remoteName, w.bundleUrl);
17021
+ }
16937
17022
  const toMetadata = (widgetId, entry) => ({
16938
17023
  widgetId,
16939
17024
  addonId: entry.addonId,
16940
17025
  stableId: entry.stableId,
17026
+ tab: entry.tab,
17027
+ subTab: entry.subTab,
16941
17028
  label: entry.label,
17029
+ order: entry.order,
17030
+ kind: entry.kind,
17031
+ remote: entry.remote,
16942
17032
  description: entry.description,
16943
17033
  icon: entry.icon,
16944
- remoteName: entry.remoteName,
16945
17034
  bundleUrl: entry.bundleUrl,
16946
17035
  hosts: entry.hosts,
16947
17036
  requires: entry.requires,
@@ -16954,9 +17043,9 @@ function WidgetRegistryProvider({ children }) {
16954
17043
  resolve: (widgetId) => {
16955
17044
  const entry = byId.get(widgetId);
16956
17045
  if (!entry) return void 0;
16957
- const bundle = bundleModuleCache.get(entry.remoteName);
17046
+ const bundle = peekBundle(entry.remote.remoteName, entry.remote.exposedModule);
16958
17047
  if (!bundle) return null;
16959
- const Component = bundle[entry.stableId];
17048
+ const Component = bundle[entry.remote.componentKey ?? entry.stableId];
16960
17049
  if (!Component) return void 0;
16961
17050
  return Component;
16962
17051
  },
@@ -16969,15 +17058,23 @@ function WidgetRegistryProvider({ children }) {
16969
17058
  const out = [];
16970
17059
  for (const [widgetId, entry] of byId) out.push(toMetadata(widgetId, entry));
16971
17060
  return out;
16972
- }
17061
+ },
17062
+ entryUrlFor: (remoteName) => entryUrlByRemote.get(remoteName)
16973
17063
  };
16974
- }, [rawWidgets, resolvedTick]);
17064
+ }, [widgets, resolvedTick]);
16975
17065
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetRegistryContext.Provider, {
16976
17066
  value: registry,
16977
17067
  children
16978
17068
  });
16979
17069
  }
16980
- /** Returns the registered widget component, `null` while loading, or `undefined` if unknown. */
17070
+ /**
17071
+ * Returns the registered widget component, `null` while loading, or
17072
+ * `undefined` if unknown.
17073
+ *
17074
+ * @deprecated Unused by all render paths since the unified
17075
+ * UI-contribution model (Task 10). Use `useRemoteComponent` /
17076
+ * `<WidgetSlot>` instead. Kept for external addons that may import it.
17077
+ */
16981
17078
  function useWidget(widgetId) {
16982
17079
  return useWidgetRegistry().resolve(widgetId);
16983
17080
  }
@@ -16991,6 +17088,68 @@ function useWidgetMetadata(widgetId) {
16991
17088
  function useAllWidgets() {
16992
17089
  return useWidgetRegistry().listAll();
16993
17090
  }
17091
+ /**
17092
+ * Resolve a `kind:'remote'` `UiContributionRemote` descriptor to a
17093
+ * React component. Drives the unified `ContributionRenderer`
17094
+ * `kind:'remote'` path — both device-detail contributions and dashboard
17095
+ * widgets resolve through the same MF `loadRemoteBundle` loader.
17096
+ *
17097
+ * Returns:
17098
+ * - `undefined` — the remote couldn't be resolved (no entry URL known,
17099
+ * or the remote exposed no component for `componentKey`),
17100
+ * - `null` — the bundle is still loading,
17101
+ * - the component otherwise.
17102
+ *
17103
+ * `entryUrl` may be passed explicitly; when omitted it's resolved from
17104
+ * the registry's aggregator-stamped `bundleUrl` for `remote.remoteName`.
17105
+ */
17106
+ function useRemoteComponent(remote, entryUrl) {
17107
+ const reg = useOptionalWidgetRegistry();
17108
+ const resolvedEntryUrl = entryUrl ?? reg?.entryUrlFor(remote.remoteName);
17109
+ const [tick, setTick] = (0, react$1.useState)(0);
17110
+ const [loadFailed, setLoadFailed] = (0, react$1.useState)(false);
17111
+ (0, react$1.useEffect)(() => {
17112
+ if (!resolvedEntryUrl) return;
17113
+ setLoadFailed(false);
17114
+ if (peekBundle(remote.remoteName, remote.exposedModule)) return;
17115
+ let cancelled = false;
17116
+ loadRemoteBundle(remote.remoteName, remote.exposedModule, resolvedEntryUrl).then(() => {
17117
+ if (!cancelled) setTick((t) => t + 1);
17118
+ }).catch((err) => {
17119
+ const reason = err instanceof Error ? err.message : String(err);
17120
+ (typeof globalThis !== "undefined" ? globalThis.console : void 0)?.error?.("[WidgetRegistry] Failed to load remote component", {
17121
+ remoteName: remote.remoteName,
17122
+ exposedModule: remote.exposedModule,
17123
+ entryUrl: resolvedEntryUrl,
17124
+ reason
17125
+ });
17126
+ if (!cancelled) setLoadFailed(true);
17127
+ });
17128
+ return () => {
17129
+ cancelled = true;
17130
+ };
17131
+ }, [
17132
+ remote.remoteName,
17133
+ remote.exposedModule,
17134
+ resolvedEntryUrl
17135
+ ]);
17136
+ return (0, react$1.useMemo)(() => {
17137
+ if (!resolvedEntryUrl) return void 0;
17138
+ if (loadFailed) return void 0;
17139
+ const bundle = peekBundle(remote.remoteName, remote.exposedModule);
17140
+ if (!bundle) return null;
17141
+ const componentKey = remote.componentKey;
17142
+ if (componentKey === void 0) return Object.values(bundle)[0] ?? void 0;
17143
+ return bundle[componentKey] ?? void 0;
17144
+ }, [
17145
+ remote.remoteName,
17146
+ remote.exposedModule,
17147
+ remote.componentKey,
17148
+ resolvedEntryUrl,
17149
+ tick,
17150
+ loadFailed
17151
+ ]);
17152
+ }
16994
17153
  /** Read the registry instance — throws when no provider is mounted. */
16995
17154
  function useWidgetRegistry() {
16996
17155
  const ctx = useOptionalWidgetRegistry();
@@ -17005,163 +17164,1429 @@ function useContextSafe(ctx) {
17005
17164
  return (0, react$1.useContext)(ctx);
17006
17165
  }
17007
17166
  //#endregion
17008
- //#region src/composites/widget-slot.tsx
17167
+ //#region src/hooks/use-ptz.ts
17009
17168
  /**
17010
- * <WidgetSlot>single host-side mount point for any addon-contributed
17011
- * widget. Consumers reference a widget by its public id
17012
- * (`<addonId>/<stableId>`), the slot looks the component up in the
17013
- * shared `WidgetRegistry`, validates host context against the widget's
17014
- * `requires` metadata, and renders one of:
17015
- * - skeleton placeholder while the bundle is still loading,
17016
- * - inline error fallback when the widget id is unknown OR the host
17017
- * didn't supply a required context (`deviceContext` /
17018
- * `integrationContext`),
17019
- * - the resolved component otherwise.
17169
+ * usePTZPTZ control hook for device-scoped pan / tilt / zoom.
17020
17170
  *
17021
- * The slot is intentionally STUPID no styling beyond the skeleton/
17022
- * error fallback. Layout (card vs inline, sizing) is the host's
17023
- * responsibility.
17171
+ * Wraps the `ptz` capability methods through the canonical
17172
+ * `useDeviceProxy` surface every call goes through
17173
+ * `dev.ptz?.<method>(...)` with deviceId/nodeId auto-injected. The
17174
+ * hook stays bare-bones around state (`busy`, `error`, `presets`)
17175
+ * so the operator-facing component (PTZOverlay) stays trivial.
17176
+ *
17177
+ * Returns:
17178
+ * - `move(direction)`: discrete one-shot move (cap.move + cap.stop).
17179
+ * Use for short pulse moves triggered by tapping a d-pad button.
17180
+ * - `startContinuous(direction)` / `stopContinuous()`: gesture-driven
17181
+ * continuous motion (`cap.continuousMove` + `cap.stop`). Use for
17182
+ * long-press handlers on touch / mouse-down handlers on desktop.
17183
+ * - `zoom('in' | 'out')`: discrete zoom step.
17184
+ * - `goHome()`: jump to preset 0.
17185
+ * - `presets` + `goToPreset(presetId)`: list and jump to named presets.
17186
+ *
17187
+ * Parametrised by the same `UseDeviceProxyTrpc` shape every other
17188
+ * device hook uses — works under admin-ui (BackendClient.trpc) and
17189
+ * addon pages (AddonPageProps.trpc) alike.
17024
17190
  */
17025
- function WidgetSlot(props) {
17026
- const { widgetId, host = "device-tab", config, deviceId, integrationId, instanceId, size, columns, rows } = props;
17027
- const Component = useWidget(widgetId);
17028
- const metadata = useWidgetMetadata(widgetId);
17029
- const resolvedInstanceId = (0, react$1.useMemo)(() => instanceId ?? widgetId, [instanceId, widgetId]);
17030
- if (Component === void 0 && metadata === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
17031
- widgetId,
17032
- reason: "unknown"
17033
- });
17034
- if (Component === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
17035
- widgetId,
17036
- reason: "missing-export"
17037
- });
17038
- if (Component === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetSkeleton, {});
17039
- if (metadata) {
17040
- if (metadata.requires.deviceContext && deviceId === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
17041
- widgetId,
17042
- reason: "missing-device-context"
17043
- });
17044
- if (metadata.requires.integrationContext && integrationId === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
17045
- widgetId,
17046
- reason: "missing-integration-context"
17047
- });
17191
+ var DIRECTION_VECTORS = {
17192
+ "up": {
17193
+ pan: 0,
17194
+ tilt: 1
17195
+ },
17196
+ "down": {
17197
+ pan: 0,
17198
+ tilt: -1
17199
+ },
17200
+ "left": {
17201
+ pan: -1,
17202
+ tilt: 0
17203
+ },
17204
+ "right": {
17205
+ pan: 1,
17206
+ tilt: 0
17207
+ },
17208
+ "up-left": {
17209
+ pan: -1,
17210
+ tilt: 1
17211
+ },
17212
+ "up-right": {
17213
+ pan: 1,
17214
+ tilt: 1
17215
+ },
17216
+ "down-left": {
17217
+ pan: -1,
17218
+ tilt: -1
17219
+ },
17220
+ "down-right": {
17221
+ pan: 1,
17222
+ tilt: -1
17048
17223
  }
17049
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Component, {
17050
- instanceId: resolvedInstanceId,
17051
- host,
17052
- config,
17053
- deviceId,
17054
- integrationId,
17055
- size,
17056
- columns,
17057
- rows
17058
- });
17059
- }
17060
- function WidgetSkeleton() {
17061
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17062
- className: "rounded-lg border border-border bg-surface/40 p-4 animate-pulse",
17063
- children: [
17064
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-3 w-24 bg-foreground-subtle/20 rounded mb-2" }),
17065
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-2 w-full bg-foreground-subtle/10 rounded mb-1" }),
17066
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-2 w-3/4 bg-foreground-subtle/10 rounded" })
17067
- ]
17068
- });
17069
- }
17070
- function WidgetMissingError({ widgetId, reason }) {
17071
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17072
- className: "rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning",
17073
- children: reason === "unknown" ? `Widget "${widgetId}" not registered` : reason === "missing-export" ? `Widget "${widgetId}" bundle does not export this stableId` : reason === "missing-device-context" ? `Widget "${widgetId}" requires a deviceId` : `Widget "${widgetId}" requires an integrationId`
17074
- });
17075
- }
17076
- //#endregion
17077
- //#region src/composites/config-form-field.tsx
17078
- var INPUT_CLASS = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
17079
- var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1";
17080
- var DESC_CLASS = "text-[10px] text-foreground-subtle mt-0.5";
17081
- function FieldWrapper({ label, description, required, span, children, translationFn }) {
17082
- const colSpanClass = span === 2 ? "col-span-2" : span === 3 ? "col-span-3" : span === 4 ? "col-span-4" : "col-span-1";
17083
- const resolvedLabel = resolveLabel(label, translationFn);
17084
- const resolvedDescription = resolveLabel(description, translationFn);
17085
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17086
- className: colSpanClass,
17087
- children: [
17088
- resolvedLabel !== void 0 && resolvedLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17089
- className: LABEL_CLASS,
17090
- children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17091
- className: "text-danger ml-0.5",
17092
- children: "*"
17093
- })]
17094
- }),
17095
- children,
17096
- resolvedDescription && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
17097
- className: DESC_CLASS,
17098
- children: resolvedDescription
17099
- })
17100
- ]
17101
- });
17102
- }
17103
- function TextField({ field, value, onChange, disabled, translationFn }) {
17104
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldWrapper, {
17105
- label: field.label,
17106
- description: field.description,
17107
- required: field.required,
17108
- span: field.span,
17109
- translationFn,
17110
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17111
- type: field.inputType ?? "text",
17112
- className: INPUT_CLASS,
17113
- value: value === void 0 || value === null ? "" : String(value),
17114
- placeholder: field.placeholder,
17115
- maxLength: field.maxLength,
17116
- pattern: field.pattern,
17117
- disabled: disabled || field.disabled,
17118
- onChange: (e) => onChange(e.target.value)
17119
- })
17120
- });
17121
- }
17122
- function NumberField({ field, value, onChange, disabled, translationFn }) {
17123
- const [local, setLocal] = (0, react$1.useState)(value === void 0 || value === null ? "" : String(value));
17124
- const focusedRef = (0, react$1.useRef)(false);
17224
+ };
17225
+ function usePTZ(trpc, deviceId, hookOptions) {
17226
+ const defaultSpeed = hookOptions?.defaultSpeed ?? .5;
17227
+ const pulseMs = hookOptions?.pulseMs ?? 250;
17228
+ const enabled = hookOptions?.enabled ?? true;
17229
+ const ptz = useDeviceProxy(trpc, enabled ? deviceId : null)?.ptz;
17230
+ const [presets, setPresets] = (0, react$1.useState)([]);
17231
+ const [options, setOptions] = (0, react$1.useState)(null);
17232
+ const [busy, setBusy] = (0, react$1.useState)(false);
17233
+ const [error, setError] = (0, react$1.useState)(null);
17234
+ const isAbsentProvider = (err) => {
17235
+ const msg = err instanceof Error ? err.message : String(err);
17236
+ return msg.includes("provider not available") || msg.includes("no 'ptz' binding");
17237
+ };
17238
+ const refreshPresets = (0, react$1.useCallback)(async () => {
17239
+ if (!enabled || !ptz) return;
17240
+ try {
17241
+ setPresets(await ptz.getPresets({}));
17242
+ } catch (err) {
17243
+ if (isAbsentProvider(err)) return;
17244
+ setError(err instanceof Error ? err.message : String(err));
17245
+ }
17246
+ }, [ptz, enabled]);
17247
+ const refreshOptions = (0, react$1.useCallback)(async () => {
17248
+ if (!enabled || !ptz) return;
17249
+ try {
17250
+ setOptions(await ptz.getOptions({}));
17251
+ } catch (err) {
17252
+ if (isAbsentProvider(err)) return;
17253
+ setError(err instanceof Error ? err.message : String(err));
17254
+ }
17255
+ }, [ptz, enabled]);
17125
17256
  (0, react$1.useEffect)(() => {
17126
- if (focusedRef.current) return;
17127
- setLocal(value === void 0 || value === null ? "" : String(value));
17128
- }, [value]);
17129
- const handleChange = (raw) => {
17130
- setLocal(raw);
17131
- if (raw === "" || raw === "-") {
17132
- onChange(void 0);
17257
+ refreshPresets();
17258
+ refreshOptions();
17259
+ }, [refreshPresets, refreshOptions]);
17260
+ const wrap = (0, react$1.useCallback)(async (fn) => {
17261
+ if (!enabled || !ptz) return void 0;
17262
+ setError(null);
17263
+ setBusy(true);
17264
+ try {
17265
+ return await fn();
17266
+ } catch (err) {
17267
+ setError(err instanceof Error ? err.message : String(err));
17133
17268
  return;
17269
+ } finally {
17270
+ setBusy(false);
17134
17271
  }
17135
- const parsed = Number(raw);
17136
- if (Number.isNaN(parsed)) return;
17137
- onChange(parsed);
17138
- };
17139
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldWrapper, {
17140
- label: field.label,
17141
- description: field.description,
17142
- required: field.required,
17143
- span: field.span,
17144
- translationFn,
17145
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17146
- className: "flex items-center gap-1",
17147
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17148
- type: "number",
17149
- className: INPUT_CLASS,
17150
- value: local,
17151
- placeholder: field.placeholder,
17152
- min: field.min,
17153
- max: field.max,
17154
- step: field.step,
17155
- disabled: disabled || field.disabled,
17156
- onFocus: () => {
17157
- focusedRef.current = true;
17158
- },
17159
- onBlur: () => {
17160
- focusedRef.current = false;
17161
- },
17162
- onChange: (e) => handleChange(e.target.value)
17163
- }), field.unit && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17164
- className: "text-xs text-foreground-subtle whitespace-nowrap",
17272
+ }, [enabled, ptz]);
17273
+ return {
17274
+ move: (0, react$1.useCallback)(async (direction, speed) => {
17275
+ if (!ptz) return;
17276
+ const v = DIRECTION_VECTORS[direction];
17277
+ const s = speed ?? defaultSpeed;
17278
+ await wrap(async () => {
17279
+ await ptz.move({
17280
+ pan: v.pan,
17281
+ tilt: v.tilt,
17282
+ speed: s
17283
+ });
17284
+ await new Promise((r) => setTimeout(r, pulseMs));
17285
+ await ptz.stop({});
17286
+ });
17287
+ }, [
17288
+ ptz,
17289
+ defaultSpeed,
17290
+ pulseMs,
17291
+ wrap
17292
+ ]),
17293
+ startContinuous: (0, react$1.useCallback)(async (direction, speed) => {
17294
+ if (!ptz) return;
17295
+ const v = DIRECTION_VECTORS[direction];
17296
+ const s = speed ?? defaultSpeed;
17297
+ await wrap(() => ptz.continuousMove({
17298
+ pan: v.pan,
17299
+ tilt: v.tilt,
17300
+ speed: s
17301
+ }));
17302
+ }, [
17303
+ ptz,
17304
+ defaultSpeed,
17305
+ wrap
17306
+ ]),
17307
+ stopContinuous: (0, react$1.useCallback)(async () => {
17308
+ if (!ptz) return;
17309
+ await wrap(() => ptz.stop({}));
17310
+ }, [ptz, wrap]),
17311
+ zoom: (0, react$1.useCallback)(async (direction, speed) => {
17312
+ if (!ptz) return;
17313
+ const z = direction === "in" ? 1 : -1;
17314
+ const s = speed ?? defaultSpeed;
17315
+ await wrap(async () => {
17316
+ await ptz.move({
17317
+ zoom: z,
17318
+ speed: s
17319
+ });
17320
+ await new Promise((r) => setTimeout(r, pulseMs));
17321
+ await ptz.stop({});
17322
+ });
17323
+ }, [
17324
+ ptz,
17325
+ defaultSpeed,
17326
+ pulseMs,
17327
+ wrap
17328
+ ]),
17329
+ goHome: (0, react$1.useCallback)(async () => {
17330
+ if (!ptz) return;
17331
+ await wrap(() => ptz.goHome({}));
17332
+ }, [ptz, wrap]),
17333
+ goToPreset: (0, react$1.useCallback)(async (presetId) => {
17334
+ if (!ptz) return;
17335
+ await wrap(() => ptz.goToPreset({ presetId }));
17336
+ }, [ptz, wrap]),
17337
+ savePreset: (0, react$1.useCallback)(async (name) => {
17338
+ if (!ptz) return void 0;
17339
+ return wrap(async () => {
17340
+ const usedIds = new Set(presets.map((p) => Number(p.id)).filter((n) => Number.isInteger(n) && n >= 0));
17341
+ let nextId = 0;
17342
+ while (usedIds.has(nextId)) nextId += 1;
17343
+ const presetId = String(nextId);
17344
+ await ptz.savePreset({
17345
+ presetId,
17346
+ name
17347
+ });
17348
+ await refreshPresets();
17349
+ return presetId;
17350
+ });
17351
+ }, [
17352
+ ptz,
17353
+ presets,
17354
+ refreshPresets,
17355
+ wrap
17356
+ ]),
17357
+ deletePreset: (0, react$1.useCallback)(async (presetId) => {
17358
+ if (!ptz) return;
17359
+ await wrap(async () => {
17360
+ await ptz.deletePreset({ presetId });
17361
+ await refreshPresets();
17362
+ });
17363
+ }, [
17364
+ ptz,
17365
+ refreshPresets,
17366
+ wrap
17367
+ ]),
17368
+ presets,
17369
+ refreshPresets,
17370
+ options,
17371
+ busy,
17372
+ error
17373
+ };
17374
+ }
17375
+ //#endregion
17376
+ //#region src/composites/ptz-overlay.tsx
17377
+ /**
17378
+ * PTZOverlay — pan / tilt / zoom controls.
17379
+ *
17380
+ * Two visual variants driven by `mode`:
17381
+ * - `'overlay'` (default): translucent dark pill positioned bottom-
17382
+ * right of the camera viewport. Used as `extraOverlay` on the
17383
+ * `CameraStreamPlayer` so operators can drive PTZ without leaving
17384
+ * the live view. Opaque background + subtle ring keeps the d-pad
17385
+ * legible against any frame.
17386
+ * - `'panel'`: full-bleed inside a host container (e.g. the floating
17387
+ * PTZ panel in DeviceDetail). No absolute positioning, no inner
17388
+ * wrapper card — the host's chrome is the only frame. Inherits the
17389
+ * surrounding `bg-surface` so the dark theme reads consistently
17390
+ * instead of the previous always-dark-bubble look.
17391
+ *
17392
+ * Interaction model is identical across modes: short tap fires a
17393
+ * discrete pulse (`move`); long press starts continuous motion until
17394
+ * release (`startContinuous` + `stopContinuous` on pointer up).
17395
+ */
17396
+ function DPadButton({ direction, icon: Icon, disabled, className, variant, onMove, onStart, onStop }) {
17397
+ const [pressedAt, setPressedAt] = (0, react$1.useState)(null);
17398
+ const [continuous, setContinuous] = (0, react$1.useState)(false);
17399
+ const handlePointerDown = (0, react$1.useCallback)((e) => {
17400
+ if (disabled) return;
17401
+ e.currentTarget.setPointerCapture(e.pointerId);
17402
+ setPressedAt(Date.now());
17403
+ setContinuous(false);
17404
+ const timer = setTimeout(() => {
17405
+ setContinuous(true);
17406
+ onStart(direction);
17407
+ }, 250);
17408
+ e.currentTarget.dataset.timer = String(timer);
17409
+ }, [
17410
+ direction,
17411
+ disabled,
17412
+ onStart
17413
+ ]);
17414
+ const handlePointerUp = (0, react$1.useCallback)((e) => {
17415
+ const timerId = Number(e.currentTarget.dataset.timer);
17416
+ if (timerId) clearTimeout(timerId);
17417
+ e.currentTarget.dataset.timer = "";
17418
+ if (continuous) onStop();
17419
+ else if (pressedAt !== null) onMove(direction);
17420
+ setPressedAt(null);
17421
+ setContinuous(false);
17422
+ }, [
17423
+ continuous,
17424
+ direction,
17425
+ onMove,
17426
+ onStop,
17427
+ pressedAt
17428
+ ]);
17429
+ const handlePointerCancel = (0, react$1.useCallback)((e) => {
17430
+ const timerId = Number(e.currentTarget.dataset.timer);
17431
+ if (timerId) clearTimeout(timerId);
17432
+ e.currentTarget.dataset.timer = "";
17433
+ if (continuous) onStop();
17434
+ setPressedAt(null);
17435
+ setContinuous(false);
17436
+ }, [continuous, onStop]);
17437
+ const sizeClass = variant === "panel" ? "h-9 w-9" : "h-7 w-7";
17438
+ const iconSizeClass = variant === "panel" ? "h-4 w-4" : "h-3.5 w-3.5";
17439
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17440
+ type: "button",
17441
+ disabled,
17442
+ onPointerDown: handlePointerDown,
17443
+ onPointerUp: handlePointerUp,
17444
+ onPointerCancel: handlePointerCancel,
17445
+ className: cn("flex items-center justify-center rounded transition-colors disabled:opacity-40", variant === "panel" ? "text-foreground hover:bg-surface-hover active:bg-surface-hover/80" : "text-white hover:bg-white/15 active:bg-white/25", sizeClass, className),
17446
+ title: direction,
17447
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: iconSizeClass })
17448
+ });
17449
+ }
17450
+ function PTZOverlay({ controls, mode = "overlay", showPresets, showZoom = true, showHome = true, className }) {
17451
+ const { move, startContinuous, stopContinuous, zoom, goHome, goToPreset, savePreset, deletePreset, presets, options, busy, error } = controls;
17452
+ const confirm = useConfirm();
17453
+ const [presetsOpen, setPresetsOpen] = (0, react$1.useState)(false);
17454
+ const [presetName, setPresetName] = (0, react$1.useState)("");
17455
+ const presetsVisible = (showPresets ?? presets.length > 0) && presets.length > 0;
17456
+ const isPanel = mode === "panel";
17457
+ const presetManagementVisible = isPanel && (options?.supportsPresets ?? false);
17458
+ const maxPresetsReached = options?.maxPresets !== void 0 && presets.length >= options.maxPresets;
17459
+ const handleSavePreset = (0, react$1.useCallback)(async () => {
17460
+ const name = presetName.trim();
17461
+ if (!name || busy || maxPresetsReached) return;
17462
+ if (await savePreset(name) !== void 0) setPresetName("");
17463
+ }, [
17464
+ presetName,
17465
+ busy,
17466
+ maxPresetsReached,
17467
+ savePreset
17468
+ ]);
17469
+ const handleDeletePreset = (0, react$1.useCallback)(async (presetId, label) => {
17470
+ if (!await confirm({
17471
+ title: "Delete preset",
17472
+ message: `Delete PTZ preset "${label}"? This removes it from the camera.`,
17473
+ confirmLabel: "Delete",
17474
+ variant: "danger"
17475
+ })) return;
17476
+ await deletePreset(presetId);
17477
+ }, [confirm, deletePreset]);
17478
+ const containerClass = isPanel ? "flex flex-col items-stretch gap-2 w-full h-full p-3" : "pointer-events-auto absolute top-3 right-3 z-10 flex flex-col items-end gap-2";
17479
+ const rowClass = isPanel ? cn("flex items-center gap-3 rounded-lg p-2", busy && "ring-1 ring-primary/40") : cn("flex items-center gap-2 rounded-lg border border-white/30 bg-zinc-900/90 backdrop-blur-md p-2 shadow-lg shadow-black/40", busy && "ring-1 ring-primary/40");
17480
+ const sideButtonSize = isPanel ? "h-9 w-9" : "h-7 w-7";
17481
+ const sideIconSize = isPanel ? "h-4 w-4" : "h-3.5 w-3.5";
17482
+ const sideButtonHover = isPanel ? "text-foreground hover:bg-surface-hover" : "text-white hover:bg-white/15";
17483
+ const sepClass = isPanel ? "h-12 w-px bg-border mx-1" : "h-12 w-px bg-white/15 mx-1";
17484
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17485
+ className: cn(containerClass, className),
17486
+ children: [
17487
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17488
+ className: "rounded bg-danger/90 px-2 py-1 text-[10px] font-medium text-white shadow-lg max-w-[200px] self-center",
17489
+ children: ["PTZ: ", error]
17490
+ }),
17491
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17492
+ className: cn(rowClass, isPanel && "justify-center"),
17493
+ children: [
17494
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17495
+ className: "grid grid-cols-3 gap-0.5",
17496
+ children: [
17497
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17498
+ "aria-hidden": true,
17499
+ className: isPanel ? "h-9 w-9" : "h-7 w-7"
17500
+ }),
17501
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
17502
+ direction: "up",
17503
+ icon: ArrowUp,
17504
+ variant: mode,
17505
+ onMove: move,
17506
+ onStart: startContinuous,
17507
+ onStop: stopContinuous
17508
+ }),
17509
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17510
+ "aria-hidden": true,
17511
+ className: isPanel ? "h-9 w-9" : "h-7 w-7"
17512
+ }),
17513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
17514
+ direction: "left",
17515
+ icon: ArrowLeft,
17516
+ variant: mode,
17517
+ onMove: move,
17518
+ onStart: startContinuous,
17519
+ onStop: stopContinuous
17520
+ }),
17521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17522
+ "aria-hidden": true,
17523
+ className: isPanel ? "h-9 w-9" : "h-7 w-7"
17524
+ }),
17525
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
17526
+ direction: "right",
17527
+ icon: ArrowRight,
17528
+ variant: mode,
17529
+ onMove: move,
17530
+ onStart: startContinuous,
17531
+ onStop: stopContinuous
17532
+ }),
17533
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17534
+ "aria-hidden": true,
17535
+ className: isPanel ? "h-9 w-9" : "h-7 w-7"
17536
+ }),
17537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
17538
+ direction: "down",
17539
+ icon: ArrowDown,
17540
+ variant: mode,
17541
+ onMove: move,
17542
+ onStart: startContinuous,
17543
+ onStop: stopContinuous
17544
+ }),
17545
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17546
+ "aria-hidden": true,
17547
+ className: isPanel ? "h-9 w-9" : "h-7 w-7"
17548
+ })
17549
+ ]
17550
+ }),
17551
+ (showZoom || showHome) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: sepClass }),
17552
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17553
+ className: "flex flex-col gap-0.5",
17554
+ children: [showZoom && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17555
+ type: "button",
17556
+ onClick: () => zoom("in"),
17557
+ disabled: busy,
17558
+ title: "Zoom in",
17559
+ className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
17560
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { className: sideIconSize })
17561
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17562
+ type: "button",
17563
+ onClick: () => zoom("out"),
17564
+ disabled: busy,
17565
+ title: "Zoom out",
17566
+ className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
17567
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { className: sideIconSize })
17568
+ })] }), showHome && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17569
+ type: "button",
17570
+ onClick: () => goHome(),
17571
+ disabled: busy,
17572
+ title: "Go home",
17573
+ className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
17574
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: sideIconSize })
17575
+ })]
17576
+ }),
17577
+ presetsVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17578
+ className: "relative",
17579
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17580
+ type: "button",
17581
+ onClick: () => setPresetsOpen((v) => !v),
17582
+ disabled: busy,
17583
+ className: cn("flex items-center gap-1 rounded px-2 text-[10px] disabled:opacity-40", isPanel ? "h-9 text-foreground hover:bg-surface-hover" : "h-7 text-white hover:bg-white/15"),
17584
+ title: "Presets",
17585
+ children: ["Presets", /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: cn("h-3 w-3 transition-transform", presetsOpen && "rotate-180") })]
17586
+ }), presetsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17587
+ className: cn("absolute right-0 top-full mt-1 min-w-[140px] rounded-lg shadow-xl py-1 z-20", isPanel ? "bg-surface border border-border" : "border border-white/30 bg-zinc-900/95 backdrop-blur-md"),
17588
+ children: presets.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17589
+ className: cn("group flex items-center", isPanel ? "hover:bg-surface-hover" : "hover:bg-white/15"),
17590
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17591
+ type: "button",
17592
+ onClick: () => {
17593
+ goToPreset(p.id);
17594
+ setPresetsOpen(false);
17595
+ },
17596
+ disabled: busy,
17597
+ className: cn("flex-1 px-3 py-1.5 text-left text-[10px] disabled:opacity-40", isPanel ? "text-foreground" : "text-white"),
17598
+ children: p.name || p.id
17599
+ }), presetManagementVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17600
+ type: "button",
17601
+ onClick: () => void handleDeletePreset(p.id, p.name || p.id),
17602
+ disabled: busy,
17603
+ title: `Delete preset ${p.name || p.id}`,
17604
+ className: cn("mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded", "text-foreground-subtle hover:bg-danger/15 hover:text-danger disabled:opacity-40"),
17605
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-3 w-3" })
17606
+ })]
17607
+ }, p.id))
17608
+ })]
17609
+ })
17610
+ ]
17611
+ }),
17612
+ presetManagementVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17613
+ className: "flex flex-col gap-1 rounded-lg border border-border p-2",
17614
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17615
+ className: "flex items-center gap-2",
17616
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17617
+ type: "text",
17618
+ value: presetName,
17619
+ onChange: (e) => setPresetName(e.target.value),
17620
+ onKeyDown: (e) => {
17621
+ if (e.key === "Enter") handleSavePreset();
17622
+ },
17623
+ disabled: busy || maxPresetsReached,
17624
+ placeholder: "New preset name",
17625
+ maxLength: 64,
17626
+ className: cn("min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1 text-[11px] text-foreground", "placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary/50", "disabled:opacity-40")
17627
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17628
+ type: "button",
17629
+ onClick: () => void handleSavePreset(),
17630
+ disabled: busy || maxPresetsReached || presetName.trim().length === 0,
17631
+ title: maxPresetsReached ? `Camera preset limit reached (${options?.maxPresets})` : "Save current position as a new preset",
17632
+ className: cn("flex h-7 shrink-0 items-center gap-1 rounded px-2 text-[10px] font-medium", "bg-primary/15 text-primary hover:bg-primary/25 disabled:opacity-40"),
17633
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Plus, { className: "h-3 w-3" }), "Save current position"]
17634
+ })]
17635
+ }), maxPresetsReached && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17636
+ className: "text-[10px] text-foreground-subtle",
17637
+ children: [
17638
+ "Camera preset limit reached (",
17639
+ options?.maxPresets,
17640
+ "). Delete a preset to add a new one."
17641
+ ]
17642
+ })]
17643
+ })
17644
+ ]
17645
+ });
17646
+ }
17647
+ //#endregion
17648
+ //#region src/composites/cap-settings/PtzPanel.tsx
17649
+ /**
17650
+ * PtzPanel — live pan/tilt/zoom controls for the `ptz` capability. A
17651
+ * cap-settings component (ui-library), mounted as a cap-contributed
17652
+ * top-level device-detail tab via the cap-UI contribution mechanism
17653
+ * (the `ptz` cap declares `ui: { tab: 'ptz', kind: 'static', ... }`).
17654
+ *
17655
+ * Consolidates the former admin-ui `PTZPanelContent`: `usePTZ` drives
17656
+ * the controls, `PTZOverlay` renders them (`mode='panel'`), and an
17657
+ * Autofocus toggle is shown only when `getOptions().hasAutofocus`.
17658
+ * Autotrack is its own cap-UI contribution (`ptz-autotrack`), mounted
17659
+ * independently by the contribution mechanism — not nested here.
17660
+ */
17661
+ /** Autofocus toggle — only mounted when `hasAutofocus`. Reads the cap's
17662
+ * `getStatus().autofocus` and drives `setAutofocus`. */
17663
+ function AutofocusToggle({ deviceId }) {
17664
+ const dev = useDeviceProxy(useSystem().trpcClient, deviceId);
17665
+ const [enabled, setEnabled] = (0, react$1.useState)(null);
17666
+ const [busy, setBusy] = (0, react$1.useState)(false);
17667
+ (0, react$1.useEffect)(() => {
17668
+ if (!dev) return void 0;
17669
+ let cancelled = false;
17670
+ (async () => {
17671
+ try {
17672
+ const status = await dev.ptz?.getStatus({});
17673
+ if (cancelled || !status) return;
17674
+ setEnabled(status.autofocus);
17675
+ } catch {}
17676
+ })();
17677
+ return () => {
17678
+ cancelled = true;
17679
+ };
17680
+ }, [dev]);
17681
+ const toggle = (0, react$1.useCallback)(async () => {
17682
+ if (!dev?.ptz || enabled === null) return;
17683
+ setBusy(true);
17684
+ try {
17685
+ const next = !enabled;
17686
+ await dev.ptz.setAutofocus({ enabled: next });
17687
+ setEnabled(next);
17688
+ } catch (err) {
17689
+ console.error("ptz.setAutofocus failed", err);
17690
+ } finally {
17691
+ setBusy(false);
17692
+ }
17693
+ }, [dev, enabled]);
17694
+ if (enabled === null) return null;
17695
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17696
+ className: "flex items-center justify-between border-t border-border/40 mt-2 pt-2",
17697
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17698
+ className: "text-[10.5px] font-semibold text-foreground",
17699
+ children: "Autofocus"
17700
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17701
+ type: "button",
17702
+ onClick: () => void toggle(),
17703
+ disabled: busy,
17704
+ "aria-pressed": enabled,
17705
+ className: `flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${enabled ? "bg-success/15 text-success hover:bg-success/25" : "bg-surface-elevated/40 text-foreground-subtle hover:bg-surface-hover hover:text-foreground"} disabled:opacity-50`,
17706
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: `h-1.5 w-1.5 rounded-full ${enabled ? "bg-success" : "bg-foreground-subtle/40"}` }), enabled ? "On" : "Off"]
17707
+ })]
17708
+ });
17709
+ }
17710
+ function PtzPanel({ deviceId }) {
17711
+ const system = useSystem();
17712
+ const ready = Number.isFinite(deviceId);
17713
+ const controls = usePTZ(system.trpcClient, ready ? deviceId : 0, { enabled: ready });
17714
+ if (!ready) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17715
+ className: "flex items-center justify-center h-full text-[11px] text-foreground-subtle italic",
17716
+ children: [
17717
+ "Device #",
17718
+ deviceId,
17719
+ " not loaded."
17720
+ ]
17721
+ });
17722
+ const hasAutofocus = controls.options?.hasAutofocus === true;
17723
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17724
+ className: "flex flex-col h-full overflow-y-auto",
17725
+ children: [
17726
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
17727
+ className: "text-[11px] font-semibold text-foreground-subtle uppercase tracking-wider mb-2",
17728
+ children: "PTZ"
17729
+ }),
17730
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PTZOverlay, {
17731
+ controls,
17732
+ mode: "panel"
17733
+ }),
17734
+ hasAutofocus && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AutofocusToggle, { deviceId })
17735
+ ]
17736
+ });
17737
+ }
17738
+ //#endregion
17739
+ //#region src/hooks/use-device-autotrack.ts
17740
+ /**
17741
+ * `useDeviceAutotrack` — typed wrapper around the `ptz-autotrack` cap.
17742
+ *
17743
+ * Surface:
17744
+ * - `status` — current `{enabled, lastChangedAt, currentSettings}`,
17745
+ * polled at 5s intervals while the hook is mounted.
17746
+ * - `setEnabled(on)` — flip the on/off state.
17747
+ * - `setSettings(partial)` — patch one or more settings keys.
17748
+ * - `isPending` — true while a mutation is in flight.
17749
+ *
17750
+ * Returns `null` for `status` when the cap isn't available (device
17751
+ * doesn't support autotrack) — callers gate render on this OR on the
17752
+ * device's `PtzAutotrack` feature flag.
17753
+ */
17754
+ function useDeviceAutotrack(deviceId) {
17755
+ const queryClient = (0, _tanstack_react_query.useQueryClient)();
17756
+ const statusQuery = usePtzAutotrackGetStatus({ deviceId: deviceId ?? 0 }, {
17757
+ enabled: deviceId !== null && Number.isFinite(deviceId),
17758
+ refetchInterval: 5e3,
17759
+ retry: 1
17760
+ });
17761
+ const setEnabledMutation = usePtzAutotrackSetEnabled({ onSuccess: () => {
17762
+ queryClient.invalidateQueries({ queryKey: [["ptzAutotrack"]] });
17763
+ } });
17764
+ const setSettingsMutation = usePtzAutotrackSetSettings({ onSuccess: () => {
17765
+ queryClient.invalidateQueries({ queryKey: [["ptzAutotrack"]] });
17766
+ } });
17767
+ const setEnabled = (0, react$1.useCallback)(async (on) => {
17768
+ if (deviceId === null) return;
17769
+ await setEnabledMutation.mutateAsync({
17770
+ deviceId,
17771
+ enabled: on
17772
+ });
17773
+ }, [deviceId, setEnabledMutation]);
17774
+ const setSettings = (0, react$1.useCallback)(async (patch) => {
17775
+ if (deviceId === null) return;
17776
+ await setSettingsMutation.mutateAsync({
17777
+ deviceId,
17778
+ settings: patch
17779
+ });
17780
+ }, [deviceId, setSettingsMutation]);
17781
+ const errorMsg = (() => {
17782
+ if (statusQuery.error) return statusQuery.error.message;
17783
+ if (setEnabledMutation.error) return setEnabledMutation.error.message;
17784
+ if (setSettingsMutation.error) return setSettingsMutation.error.message;
17785
+ return null;
17786
+ })();
17787
+ return {
17788
+ status: statusQuery.data ?? null,
17789
+ isLoading: statusQuery.isLoading,
17790
+ isPending: setEnabledMutation.isPending || setSettingsMutation.isPending,
17791
+ error: errorMsg,
17792
+ setEnabled,
17793
+ setSettings
17794
+ };
17795
+ }
17796
+ //#endregion
17797
+ //#region src/composites/cap-settings/AutotrackSection.tsx
17798
+ /**
17799
+ * Autotrack settings card — rendered inside the PTZ panel when the
17800
+ * device has `DeviceFeature.PtzAutotrack`. Three knobs (cross-vendor
17801
+ * schema): target type, stop delay, disappear delay, plus an enable /
17802
+ * disable toggle that drives the `ptz-autotrack` cap directly.
17803
+ *
17804
+ * Save semantics:
17805
+ * - Toggle (enable/disable) calls `setEnabled` immediately.
17806
+ * - Form fields debounce-save on blur — no explicit Save button.
17807
+ *
17808
+ * Vendor capability hints:
17809
+ * - The cap's status carries `currentSettings` from the camera's
17810
+ * own GET; rendered on every poll so the operator sees what's
17811
+ * really applied vs what they typed.
17812
+ * - When the firmware ignored a setting, the field stays editable.
17813
+ */
17814
+ function AutotrackSection({ deviceId }) {
17815
+ const { status, isLoading, isPending, error, setEnabled, setSettings } = useDeviceAutotrack(Number.isFinite(deviceId) ? deviceId : null);
17816
+ const [draft, setDraft] = (0, react$1.useState)({
17817
+ targetType: "",
17818
+ stopDelaySeconds: 30,
17819
+ disappearDelaySeconds: 15
17820
+ });
17821
+ const [editing, setEditing] = (0, react$1.useState)(false);
17822
+ (0, react$1.useEffect)(() => {
17823
+ if (editing) return;
17824
+ const s = status?.currentSettings;
17825
+ if (!s) return;
17826
+ setDraft({
17827
+ targetType: s.targetType,
17828
+ stopDelaySeconds: s.stopDelaySeconds,
17829
+ disappearDelaySeconds: s.disappearDelaySeconds
17830
+ });
17831
+ }, [status, editing]);
17832
+ if (isLoading && !status) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17833
+ className: "flex items-center gap-2 px-3 py-2 text-[10.5px] text-foreground-subtle",
17834
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3 w-3 animate-spin" }), "Loading autotrack…"]
17835
+ });
17836
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17837
+ className: "border-t border-border/40 mt-2 pt-2 px-1 space-y-2",
17838
+ children: [
17839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17840
+ className: "flex items-center justify-between px-2",
17841
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
17842
+ className: "flex items-center gap-1.5 text-[11px] font-semibold text-foreground-subtle uppercase tracking-wider",
17843
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Crosshair, { className: "h-3 w-3 text-primary" }), "Autotrack"]
17844
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17845
+ type: "button",
17846
+ onClick: () => {
17847
+ setEnabled(!(status?.enabled ?? false));
17848
+ },
17849
+ disabled: isPending,
17850
+ className: `flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${status?.enabled ? "bg-success/15 text-success hover:bg-success/25" : "bg-surface-elevated/40 text-foreground-subtle hover:bg-surface-hover hover:text-foreground"} disabled:opacity-50`,
17851
+ "aria-pressed": status?.enabled ?? false,
17852
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: `h-1.5 w-1.5 rounded-full ${status?.enabled ? "bg-success" : "bg-foreground-subtle/40"}` }), status?.enabled ? "Enabled" : "Disabled"]
17853
+ })]
17854
+ }),
17855
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17856
+ className: "flex items-start gap-1.5 rounded-md border border-danger/30 bg-danger/5 px-2 py-1.5 text-[10px] text-danger",
17857
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CircleAlert, { className: "h-3 w-3 flex-shrink-0 mt-0.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17858
+ className: "leading-snug break-words",
17859
+ children: error
17860
+ })]
17861
+ }),
17862
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17863
+ className: "grid grid-cols-2 gap-2 px-2",
17864
+ children: [
17865
+ (status?.supportedTargetTypes?.length ?? 0) > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17866
+ className: "col-span-2 flex flex-col gap-1",
17867
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17868
+ className: "text-[10px] font-medium text-foreground-subtle",
17869
+ children: "Target type"
17870
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
17871
+ value: draft.targetType,
17872
+ onFocus: () => setEditing(true),
17873
+ onChange: (e) => setDraft((d) => ({
17874
+ ...d,
17875
+ targetType: e.target.value
17876
+ })),
17877
+ onBlur: () => {
17878
+ setEditing(false);
17879
+ const current = status?.currentSettings?.targetType ?? "";
17880
+ if (draft.targetType !== current) setSettings({ targetType: draft.targetType });
17881
+ },
17882
+ disabled: isPending,
17883
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground focus:border-primary focus:outline-none disabled:opacity-50",
17884
+ children: (status?.supportedTargetTypes ?? []).map((opt) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17885
+ value: opt.value,
17886
+ children: opt.label
17887
+ }, opt.value))
17888
+ })]
17889
+ }),
17890
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17891
+ className: "flex flex-col gap-1",
17892
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17893
+ className: "text-[10px] font-medium text-foreground-subtle",
17894
+ children: "Stop delay (s)"
17895
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17896
+ type: "number",
17897
+ min: 0,
17898
+ max: 300,
17899
+ value: draft.stopDelaySeconds,
17900
+ onFocus: () => setEditing(true),
17901
+ onChange: (e) => setDraft((d) => ({
17902
+ ...d,
17903
+ stopDelaySeconds: Number(e.target.value) || 0
17904
+ })),
17905
+ onBlur: () => {
17906
+ setEditing(false);
17907
+ const current = status?.currentSettings?.stopDelaySeconds ?? 30;
17908
+ if (draft.stopDelaySeconds !== current) setSettings({ stopDelaySeconds: draft.stopDelaySeconds });
17909
+ },
17910
+ disabled: isPending,
17911
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground focus:border-primary focus:outline-none disabled:opacity-50"
17912
+ })]
17913
+ }),
17914
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
17915
+ className: "flex flex-col gap-1",
17916
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17917
+ className: "text-[10px] font-medium text-foreground-subtle",
17918
+ children: "Disappear delay (s)"
17919
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17920
+ type: "number",
17921
+ min: 0,
17922
+ max: 300,
17923
+ value: draft.disappearDelaySeconds,
17924
+ onFocus: () => setEditing(true),
17925
+ onChange: (e) => setDraft((d) => ({
17926
+ ...d,
17927
+ disappearDelaySeconds: Number(e.target.value) || 0
17928
+ })),
17929
+ onBlur: () => {
17930
+ setEditing(false);
17931
+ const current = status?.currentSettings?.disappearDelaySeconds ?? 15;
17932
+ if (draft.disappearDelaySeconds !== current) setSettings({ disappearDelaySeconds: draft.disappearDelaySeconds });
17933
+ },
17934
+ disabled: isPending,
17935
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground focus:border-primary focus:outline-none disabled:opacity-50"
17936
+ })]
17937
+ })
17938
+ ]
17939
+ }),
17940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
17941
+ className: "px-2 text-[9.5px] text-foreground-subtle leading-snug italic",
17942
+ children: "Settings save on blur. Vendor support varies — Reolink honours all three; Hikvision honours target type + stop delay only (disappear delay is accepted but ignored at firmware push)."
17943
+ })
17944
+ ]
17945
+ });
17946
+ }
17947
+ //#endregion
17948
+ //#region src/contexts/player-overlays.tsx
17949
+ /**
17950
+ * Player overlay registry — pluggable layers + toolbar buttons for
17951
+ * the device-detail live-frame `StreamPanel`.
17952
+ *
17953
+ * Why a registry: previously `StreamPanelContent` hardcoded each
17954
+ * overlay (PTZ, intercom toggle, zone editor) and threaded the
17955
+ * editing state by hand. Adding a new feature (audio waveform,
17956
+ * detection bbox tracker, recording controls, …) meant another
17957
+ * round of plumbing through the host file and StreamPanel's prop
17958
+ * surface. The registry lets a sibling component (Detection tab,
17959
+ * Live Stats tab, an addon-page extension) declare its own layer
17960
+ * imperatively via a hook, and the host renders whatever's
17961
+ * registered.
17962
+ *
17963
+ * Two registries live side-by-side:
17964
+ *
17965
+ * 1. **layers** — absolute-positioned React nodes drawn over the
17966
+ * video frame (zones polygon canvas, motion bbox overlay,
17967
+ * audio waveform). Rendered ordered by `order` (lower first
17968
+ * → bottom; higher → top); the last-registered layer wins
17969
+ * ties.
17970
+ *
17971
+ * 2. **toolbar buttons** — controls surfaced in the player's
17972
+ * always-visible toolbar cluster (next to Intercom + Play/Stop).
17973
+ * Each carries an icon, label, controlled `active` flag, and
17974
+ * a click handler. The host (StreamPanel) renders them
17975
+ * uniformly; tone variants stay simple (`'default' | 'primary'`).
17976
+ *
17977
+ * Lifecycle: hooks register their layer / button on mount and
17978
+ * unregister on unmount, so swapping tabs (e.g. leaving Detection)
17979
+ * automatically tears down the registration. Re-registering with
17980
+ * the same `id` replaces the prior entry — meaning a single hook
17981
+ * call can update its overlay/button props on every render without
17982
+ * leaking entries.
17983
+ *
17984
+ * Provider scope: typically wraps the whole device-detail subtree
17985
+ * so the StreamPanel + every tab share the same registry. One
17986
+ * provider per `deviceId`; switching device IDs unmounts the
17987
+ * provider naturally.
17988
+ */
17989
+ var PlayerOverlaysStateContext = createSharedContext("camstack:player-overlays-state", null);
17990
+ var PlayerOverlaysActionsContext = createSharedContext("camstack:player-overlays-actions", null);
17991
+ function PlayerOverlaysProvider({ children }) {
17992
+ const [layers, setLayers] = (0, react$1.useState)(() => /* @__PURE__ */ new Map());
17993
+ const [buttons, setButtons] = (0, react$1.useState)(() => /* @__PURE__ */ new Map());
17994
+ const setLayer = (0, react$1.useCallback)((layer) => {
17995
+ setLayers((prev) => {
17996
+ const next = new Map(prev);
17997
+ next.set(layer.id, layer);
17998
+ return next;
17999
+ });
18000
+ }, []);
18001
+ const removeLayer = (0, react$1.useCallback)((id) => {
18002
+ setLayers((prev) => {
18003
+ if (!prev.has(id)) return prev;
18004
+ const next = new Map(prev);
18005
+ next.delete(id);
18006
+ return next;
18007
+ });
18008
+ }, []);
18009
+ const setButton = (0, react$1.useCallback)((button) => {
18010
+ setButtons((prev) => {
18011
+ const next = new Map(prev);
18012
+ next.set(button.id, button);
18013
+ return next;
18014
+ });
18015
+ }, []);
18016
+ const removeButton = (0, react$1.useCallback)((id) => {
18017
+ setButtons((prev) => {
18018
+ if (!prev.has(id)) return prev;
18019
+ const next = new Map(prev);
18020
+ next.delete(id);
18021
+ return next;
18022
+ });
18023
+ }, []);
18024
+ const stateValue = (0, react$1.useMemo)(() => ({
18025
+ layers,
18026
+ buttons
18027
+ }), [layers, buttons]);
18028
+ const actionsValue = (0, react$1.useMemo)(() => ({
18029
+ setLayer,
18030
+ removeLayer,
18031
+ setButton,
18032
+ removeButton
18033
+ }), [
18034
+ setLayer,
18035
+ removeLayer,
18036
+ setButton,
18037
+ removeButton
18038
+ ]);
18039
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayerOverlaysStateContext.Provider, {
18040
+ value: stateValue,
18041
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayerOverlaysActionsContext.Provider, {
18042
+ value: actionsValue,
18043
+ children
18044
+ })
18045
+ });
18046
+ }
18047
+ /** Snapshot of registered layers ordered by `order` (asc, ties resolved
18048
+ * by insertion order of the underlying Map). Returns `[]` outside a
18049
+ * provider. */
18050
+ function usePlayerOverlayLayers() {
18051
+ const state = (0, react$1.useContext)(PlayerOverlaysStateContext);
18052
+ return (0, react$1.useMemo)(() => {
18053
+ if (!state) return [];
18054
+ return [...state.layers.values()].sort((a, b) => a.order - b.order);
18055
+ }, [state]);
18056
+ }
18057
+ /** Snapshot of registered toolbar buttons, ordered by `order` (asc). */
18058
+ function usePlayerToolbarButtons() {
18059
+ const state = (0, react$1.useContext)(PlayerOverlaysStateContext);
18060
+ return (0, react$1.useMemo)(() => {
18061
+ if (!state) return [];
18062
+ return [...state.buttons.values()].sort((a, b) => a.order - b.order);
18063
+ }, [state]);
18064
+ }
18065
+ /**
18066
+ * Register an overlay layer for the lifetime of the calling component.
18067
+ * Re-registers on every render with the latest spec; auto-unregisters
18068
+ * on unmount. Pass `null` to skip registration when the layer is
18069
+ * conditionally enabled (the hook still runs every render — keeps
18070
+ * react-hook order stable across spec === null toggles).
18071
+ *
18072
+ * Callers that build the spec inline should memoise it (`useMemo`)
18073
+ * to avoid re-registering on every parent render — context-write
18074
+ * effects depend on referential equality of the spec object.
18075
+ */
18076
+ function usePlayerOverlayLayer(spec) {
18077
+ const actions = (0, react$1.useContext)(PlayerOverlaysActionsContext);
18078
+ (0, react$1.useEffect)(() => {
18079
+ if (!actions || !spec) return void 0;
18080
+ actions.setLayer(spec);
18081
+ return () => actions.removeLayer(spec.id);
18082
+ }, [actions, spec]);
18083
+ }
18084
+ /** Same shape as `usePlayerOverlayLayer`, scoped to toolbar buttons. */
18085
+ function usePlayerToolbarButton(spec) {
18086
+ const actions = (0, react$1.useContext)(PlayerOverlaysActionsContext);
18087
+ (0, react$1.useEffect)(() => {
18088
+ if (!actions || !spec) return void 0;
18089
+ actions.setButton(spec);
18090
+ return () => actions.removeButton(spec.id);
18091
+ }, [actions, spec]);
18092
+ }
18093
+ //#endregion
18094
+ //#region src/composites/cap-settings/MotionGridCanvas.tsx
18095
+ /**
18096
+ * MotionGridCanvas — the on-frame motion-zone grid editor.
18097
+ *
18098
+ * This component is *only* the editable lattice painted over the live
18099
+ * frame: a plain CSS-grid of `gridWidth × gridHeight` cells. It carries
18100
+ * NO controls — enabled / sensitivity / save / quick-actions all live
18101
+ * in the management bar inside the settings section (`MotionZonesTab`),
18102
+ * so the live frame stays unobstructed.
18103
+ *
18104
+ * Interaction:
18105
+ * - Click a cell → toggle it.
18106
+ * - Press + drag → paint every touched cell to whatever the
18107
+ * first-touched cell flipped TO (the standard grid-mask gesture).
18108
+ *
18109
+ * Purely controlled — every change is forwarded through `onCellsChange`;
18110
+ * the owning `MotionZonesTab` keeps the single source of truth.
18111
+ */
18112
+ function MotionGridCanvas({ options, cells, onCellsChange }) {
18113
+ const { gridWidth, gridHeight } = options;
18114
+ const total = gridWidth * gridHeight;
18115
+ const paintingRef = (0, react$1.useRef)(false);
18116
+ const paintValueRef = (0, react$1.useRef)(true);
18117
+ const paintedRef = (0, react$1.useRef)(/* @__PURE__ */ new Set());
18118
+ const [, forceTick] = (0, react$1.useState)(0);
18119
+ const applyCell = (0, react$1.useCallback)((index, value) => {
18120
+ if (index < 0 || index >= total) return;
18121
+ if (cells[index] === value) return;
18122
+ const next = [...cells];
18123
+ while (next.length < total) next.push(false);
18124
+ next.length = total;
18125
+ next[index] = value;
18126
+ onCellsChange(next);
18127
+ }, [
18128
+ cells,
18129
+ total,
18130
+ onCellsChange
18131
+ ]);
18132
+ const handlePointerDown = (0, react$1.useCallback)((index) => (e) => {
18133
+ e.preventDefault();
18134
+ const nextValue = !cells[index];
18135
+ paintingRef.current = true;
18136
+ paintValueRef.current = nextValue;
18137
+ paintedRef.current = new Set([index]);
18138
+ applyCell(index, nextValue);
18139
+ forceTick((t) => t + 1);
18140
+ }, [cells, applyCell]);
18141
+ const handlePointerEnter = (0, react$1.useCallback)((index) => () => {
18142
+ if (!paintingRef.current) return;
18143
+ if (paintedRef.current.has(index)) return;
18144
+ paintedRef.current.add(index);
18145
+ applyCell(index, paintValueRef.current);
18146
+ }, [applyCell]);
18147
+ const endPaint = (0, react$1.useCallback)(() => {
18148
+ if (!paintingRef.current) return;
18149
+ paintingRef.current = false;
18150
+ paintedRef.current = /* @__PURE__ */ new Set();
18151
+ }, []);
18152
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18153
+ className: "absolute inset-0 select-none grid",
18154
+ style: {
18155
+ gridTemplateColumns: `repeat(${String(gridWidth)}, 1fr)`,
18156
+ gridTemplateRows: `repeat(${String(gridHeight)}, 1fr)`
18157
+ },
18158
+ onPointerUp: endPaint,
18159
+ onPointerLeave: endPaint,
18160
+ children: Array.from({ length: total }, (_, i) => {
18161
+ const active = cells[i] === true;
18162
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18163
+ type: "button",
18164
+ "aria-label": `motion cell ${String(i)}`,
18165
+ "aria-pressed": active,
18166
+ onPointerDown: handlePointerDown(i),
18167
+ onPointerEnter: handlePointerEnter(i),
18168
+ className: active ? "border border-primary/70 bg-primary/55 hover:bg-primary/65 transition-colors" : "border border-white/25 bg-white/10 hover:bg-white/20 transition-colors"
18169
+ }, i);
18170
+ })
18171
+ });
18172
+ }
18173
+ //#endregion
18174
+ //#region src/composites/cap-settings/MotionZonesSettings.tsx
18175
+ /**
18176
+ * MotionZonesSettings — management surface for the on-camera
18177
+ * `motion-zones` capability. A cap-settings component (ui-library),
18178
+ * mounted into the device-detail Config tab via the cap-UI
18179
+ * contribution mechanism (the `motion-zones` cap declares a `ui` block).
18180
+ *
18181
+ * Split of concerns (mirrors the analytics zones drawer):
18182
+ * - The editable GRID is painted over the live frame by
18183
+ * `MotionGridCanvas`, registered as a player-overlay layer only
18184
+ * while the operator has toggled "Edit grid" on (OFF by default).
18185
+ * - Every CONTROL lives here, in the settings section, NOT on the
18186
+ * frame: a quick-action bar (All on / All off / Invert), Save /
18187
+ * Revert, and the cell-count status.
18188
+ * - Detection enable + sensitivity are NOT edited here — they are
18189
+ * the camera's on-board motion master switch, owned by the
18190
+ * driver's "On-camera motion" settings section. The grid editor
18191
+ * only paints the mask (`setZone` with a `cells`-only patch;
18192
+ * enable / sensitivity are left untouched camera-side).
18193
+ *
18194
+ * The grid editor draws nothing on cameras without the `motion-zones`
18195
+ * cap — the fetch self-gates on a failed `getOptions`.
18196
+ */
18197
+ /** "Camera doesn't expose the cap" — swallow so the editor stays
18198
+ * hidden on unsupported devices (mirrors `usePTZ`'s gate). */
18199
+ function isAbsentProvider(err) {
18200
+ const msg = err instanceof Error ? err.message : String(err);
18201
+ return msg.includes("provider not available") || msg.includes("motion-zones");
18202
+ }
18203
+ /** Coerce an arbitrary `cells` array to exactly `gridWidth*gridHeight`. */
18204
+ function normaliseCells(cells, options) {
18205
+ const total = options.gridWidth * options.gridHeight;
18206
+ const next = new Array(total);
18207
+ for (let i = 0; i < total; i += 1) next[i] = cells[i] === true;
18208
+ return next;
18209
+ }
18210
+ function cellsEqual(a, b) {
18211
+ if (a.length !== b.length) return false;
18212
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
18213
+ return true;
18214
+ }
18215
+ function MotionZonesSettings({ deviceId }) {
18216
+ const dev = useDeviceProxy(useSystem().trpcClient, deviceId);
18217
+ const [options, setOptions] = (0, react$1.useState)(null);
18218
+ const [unsupported, setUnsupported] = (0, react$1.useState)(false);
18219
+ const [cells, setCells] = (0, react$1.useState)(null);
18220
+ const [committed, setCommitted] = (0, react$1.useState)(null);
18221
+ const [saving, setSaving] = (0, react$1.useState)(false);
18222
+ const [editingGrid, setEditingGrid] = (0, react$1.useState)(false);
18223
+ const seededRef = (0, react$1.useRef)(false);
18224
+ (0, react$1.useEffect)(() => {
18225
+ if (!dev) return void 0;
18226
+ let cancelled = false;
18227
+ (async () => {
18228
+ try {
18229
+ const opts = await dev.motionZones?.getOptions({});
18230
+ if (cancelled || !opts) return;
18231
+ setOptions(opts);
18232
+ if (seededRef.current) return;
18233
+ const status = await dev.motionZones?.getStatus({});
18234
+ if (cancelled || !status) return;
18235
+ seededRef.current = true;
18236
+ const norm = normaliseCells(status.cells, opts);
18237
+ setCommitted(norm);
18238
+ setCells(norm);
18239
+ } catch (err) {
18240
+ if (cancelled) return;
18241
+ if (isAbsentProvider(err)) setUnsupported(true);
18242
+ else console.error("motion-zones load failed", err);
18243
+ }
18244
+ })();
18245
+ return () => {
18246
+ cancelled = true;
18247
+ };
18248
+ }, [dev]);
18249
+ const dirty = (0, react$1.useMemo)(() => cells !== null && committed !== null && !cellsEqual(cells, committed), [cells, committed]);
18250
+ const activeCount = (0, react$1.useMemo)(() => cells ? cells.reduce((n, c) => c ? n + 1 : n, 0) : 0, [cells]);
18251
+ const total = options ? options.gridWidth * options.gridHeight : 0;
18252
+ const setAll = (0, react$1.useCallback)((value) => {
18253
+ if (total > 0) setCells(new Array(total).fill(value));
18254
+ }, [total]);
18255
+ const invert = (0, react$1.useCallback)(() => {
18256
+ setCells((prev) => prev ? prev.map((c) => !c) : prev);
18257
+ }, []);
18258
+ const revert = (0, react$1.useCallback)(() => {
18259
+ if (committed) setCells([...committed]);
18260
+ }, [committed]);
18261
+ const save = (0, react$1.useCallback)(async () => {
18262
+ if (!cells || !dev?.motionZones || !options) return;
18263
+ setSaving(true);
18264
+ try {
18265
+ await dev.motionZones.setZone({ patch: { cells: [...cells] } });
18266
+ const fresh = await dev.motionZones.getStatus({});
18267
+ if (fresh) {
18268
+ const norm = normaliseCells(fresh.cells, options);
18269
+ setCommitted(norm);
18270
+ setCells(norm);
18271
+ }
18272
+ } catch (err) {
18273
+ console.error("motion-zones.setZone failed", err);
18274
+ } finally {
18275
+ setSaving(false);
18276
+ }
18277
+ }, [
18278
+ cells,
18279
+ dev,
18280
+ options
18281
+ ]);
18282
+ usePlayerOverlayLayer((0, react$1.useMemo)(() => editingGrid && !unsupported && options && cells ? {
18283
+ id: "motion-zones",
18284
+ order: 110,
18285
+ node: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MotionGridCanvas, {
18286
+ options,
18287
+ cells,
18288
+ onCellsChange: setCells
18289
+ })
18290
+ } : null, [
18291
+ editingGrid,
18292
+ unsupported,
18293
+ options,
18294
+ cells
18295
+ ]));
18296
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18297
+ className: "flex flex-col gap-3",
18298
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
18299
+ className: "text-[11px] font-semibold text-foreground-subtle uppercase tracking-wider",
18300
+ children: "Motion Zones"
18301
+ }), unsupported ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18302
+ className: `${TEXT_HINT} leading-relaxed`,
18303
+ children: "This camera doesn't expose an on-board motion-detection grid."
18304
+ }) : !(!unsupported && options !== null && cells !== null) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18305
+ className: `${TEXT_HINT} leading-relaxed`,
18306
+ children: "Loading the camera's motion grid…"
18307
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
18308
+ className: `${TEXT_HINT} leading-relaxed`,
18309
+ children: [
18310
+ "Toggle",
18311
+ " ",
18312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
18313
+ className: "text-foreground",
18314
+ children: "Edit grid"
18315
+ }),
18316
+ " to paint the region the camera watches for motion directly on the live frame, then ",
18317
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
18318
+ className: "text-foreground",
18319
+ children: "Save"
18320
+ }),
18321
+ " to push the mask to the camera. Detection on/off and sensitivity are set in the",
18322
+ " ",
18323
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
18324
+ className: "text-foreground",
18325
+ children: "On-camera motion"
18326
+ }),
18327
+ " section."
18328
+ ]
18329
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18330
+ className: "flex items-center gap-2 flex-wrap",
18331
+ children: [
18332
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18333
+ type: "button",
18334
+ onClick: () => setEditingGrid((v) => !v),
18335
+ disabled: saving,
18336
+ "aria-pressed": editingGrid,
18337
+ className: editingGrid ? "rounded-md border border-primary/50 bg-primary/15 px-2.5 py-1 text-[11px] font-medium text-primary hover:bg-primary/25 disabled:opacity-40 transition-colors" : "rounded-md border border-border bg-surface px-2.5 py-1 text-[11px] font-medium text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
18338
+ children: editingGrid ? "Done editing" : "Edit grid"
18339
+ }),
18340
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18341
+ type: "button",
18342
+ onClick: () => setAll(true),
18343
+ disabled: saving,
18344
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-medium text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
18345
+ children: "All on"
18346
+ }),
18347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18348
+ type: "button",
18349
+ onClick: () => setAll(false),
18350
+ disabled: saving,
18351
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-medium text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
18352
+ children: "All off"
18353
+ }),
18354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18355
+ type: "button",
18356
+ onClick: invert,
18357
+ disabled: saving,
18358
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] font-medium text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
18359
+ children: "Invert"
18360
+ }),
18361
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
18362
+ className: `${TEXT_HINT} ml-1 tabular-nums`,
18363
+ children: [
18364
+ activeCount,
18365
+ " / ",
18366
+ total,
18367
+ " cells · ",
18368
+ options.gridWidth,
18369
+ "×",
18370
+ options.gridHeight
18371
+ ]
18372
+ }),
18373
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "flex-1" }),
18374
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18375
+ type: "button",
18376
+ onClick: revert,
18377
+ disabled: saving || !dirty,
18378
+ className: "rounded-md border border-border bg-surface px-2 py-1 text-[11px] text-foreground-subtle hover:bg-surface-hover disabled:opacity-40 transition-colors",
18379
+ children: "Revert"
18380
+ }),
18381
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18382
+ type: "button",
18383
+ onClick: () => void save(),
18384
+ disabled: saving || !dirty,
18385
+ className: "rounded-md border border-primary/50 bg-primary/15 px-2.5 py-1 text-[11px] font-medium text-primary hover:bg-primary/25 disabled:opacity-40 transition-colors",
18386
+ children: saving ? "Saving…" : "Save"
18387
+ })
18388
+ ]
18389
+ })] })]
18390
+ });
18391
+ }
18392
+ //#endregion
18393
+ //#region src/widgets/host-widgets.ts
18394
+ /** Host widgets — ui-library React components embeddable as a
18395
+ * `type:'widget'` ConfigField. `WidgetSlot` resolves these BEFORE the
18396
+ * Module-Federation `WidgetRegistry`. ids are namespaced `host/<name>`. */
18397
+ var HOST_WIDGETS = {
18398
+ "host/motion-zones-grid": MotionZonesSettings,
18399
+ "host/ptz-panel": PtzPanel,
18400
+ "host/ptz-autotrack": AutotrackSection
18401
+ };
18402
+ //#endregion
18403
+ //#region src/composites/widget-slot.tsx
18404
+ /**
18405
+ * <WidgetSlot> — single host-side mount point for any addon-contributed
18406
+ * widget. Consumers reference a widget by its public id
18407
+ * (`<addonId>/<stableId>`), the slot looks the widget's metadata up in
18408
+ * the shared `WidgetRegistry`, resolves the component through the
18409
+ * unified `useRemoteComponent` Module-Federation loader (the SAME path
18410
+ * `ContributionRenderer`'s `kind:'remote'` branch uses), validates host
18411
+ * context against the widget's `requires` metadata, and renders one of:
18412
+ * - skeleton placeholder while the bundle is still loading,
18413
+ * - inline error fallback when the widget id is unknown OR the host
18414
+ * didn't supply a required context (`deviceContext` /
18415
+ * `integrationContext`),
18416
+ * - the resolved component otherwise.
18417
+ *
18418
+ * The slot is intentionally STUPID — no styling beyond the skeleton/
18419
+ * error fallback. Layout (card vs inline, sizing) is the host's
18420
+ * responsibility.
18421
+ */
18422
+ function WidgetSlot(props) {
18423
+ const { widgetId, deviceId } = props;
18424
+ const HostComponent = HOST_WIDGETS[widgetId];
18425
+ if (HostComponent !== void 0) {
18426
+ if (deviceId === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
18427
+ widgetId,
18428
+ reason: "missing-device-context"
18429
+ });
18430
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostComponent, { deviceId });
18431
+ }
18432
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RemoteWidgetSlot, { ...props });
18433
+ }
18434
+ /**
18435
+ * Module-Federation remote-widget path — looks the widget's metadata up
18436
+ * in the shared `WidgetRegistry` and resolves it through `useRemoteComponent`.
18437
+ */
18438
+ function RemoteWidgetSlot(props) {
18439
+ const { widgetId } = props;
18440
+ const metadata = useWidgetMetadata(widgetId);
18441
+ if (metadata === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
18442
+ widgetId,
18443
+ reason: "unknown"
18444
+ });
18445
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResolvedWidgetSlot, {
18446
+ ...props,
18447
+ metadata
18448
+ });
18449
+ }
18450
+ /**
18451
+ * Inner host — resolves the widget component through the unified
18452
+ * `useRemoteComponent` MF loader (shared with `ContributionRenderer`'s
18453
+ * `kind:'remote'` branch). Mounted only once a `metadata` descriptor is
18454
+ * known, so the hook always receives a valid `remote` descriptor.
18455
+ */
18456
+ function ResolvedWidgetSlot(props) {
18457
+ const { widgetId, host = "device-tab", config, deviceId, integrationId, instanceId, size, columns, rows, metadata } = props;
18458
+ const Component = useRemoteComponent(metadata.remote, metadata.bundleUrl);
18459
+ const resolvedInstanceId = (0, react$1.useMemo)(() => instanceId ?? widgetId, [instanceId, widgetId]);
18460
+ if (Component === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetSkeleton, {});
18461
+ if (Component === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
18462
+ widgetId,
18463
+ reason: "missing-export"
18464
+ });
18465
+ if (metadata.requires.deviceContext && deviceId === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
18466
+ widgetId,
18467
+ reason: "missing-device-context"
18468
+ });
18469
+ if (metadata.requires.integrationContext && integrationId === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetMissingError, {
18470
+ widgetId,
18471
+ reason: "missing-integration-context"
18472
+ });
18473
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Component, {
18474
+ instanceId: resolvedInstanceId,
18475
+ host,
18476
+ config,
18477
+ deviceId,
18478
+ integrationId,
18479
+ size,
18480
+ columns,
18481
+ rows
18482
+ });
18483
+ }
18484
+ function WidgetSkeleton() {
18485
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18486
+ className: "rounded-lg border border-border bg-surface/40 p-4 animate-pulse",
18487
+ children: [
18488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-3 w-24 bg-foreground-subtle/20 rounded mb-2" }),
18489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-2 w-full bg-foreground-subtle/10 rounded mb-1" }),
18490
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-2 w-3/4 bg-foreground-subtle/10 rounded" })
18491
+ ]
18492
+ });
18493
+ }
18494
+ function WidgetMissingError({ widgetId, reason }) {
18495
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18496
+ className: "rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning",
18497
+ children: reason === "unknown" ? `Widget "${widgetId}" not registered` : reason === "missing-export" ? `Widget "${widgetId}" bundle does not export this stableId` : reason === "missing-device-context" ? `Widget "${widgetId}" requires a deviceId` : `Widget "${widgetId}" requires an integrationId`
18498
+ });
18499
+ }
18500
+ //#endregion
18501
+ //#region src/composites/config-form-field.tsx
18502
+ var INPUT_CLASS = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50 disabled:cursor-not-allowed";
18503
+ var LABEL_CLASS = "block text-[11px] font-medium text-foreground mb-1";
18504
+ var DESC_CLASS = "text-[10px] text-foreground-subtle mt-0.5";
18505
+ function FieldWrapper({ label, description, required, span, children, translationFn }) {
18506
+ const colSpanClass = span === 2 ? "col-span-2" : span === 3 ? "col-span-3" : span === 4 ? "col-span-4" : "col-span-1";
18507
+ const resolvedLabel = resolveLabel(label, translationFn);
18508
+ const resolvedDescription = resolveLabel(description, translationFn);
18509
+ const hasLabel = resolvedLabel !== void 0 && resolvedLabel !== "";
18510
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18511
+ className: `${colSpanClass} min-w-0`,
18512
+ children: [
18513
+ hasLabel && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
18514
+ className: LABEL_CLASS,
18515
+ children: [resolvedLabel, required && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18516
+ className: "text-danger ml-0.5",
18517
+ children: "*"
18518
+ })]
18519
+ }),
18520
+ children,
18521
+ resolvedDescription && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18522
+ className: DESC_CLASS,
18523
+ children: resolvedDescription
18524
+ })
18525
+ ]
18526
+ });
18527
+ }
18528
+ function TextField({ field, value, onChange, disabled, translationFn }) {
18529
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldWrapper, {
18530
+ label: field.label,
18531
+ description: field.description,
18532
+ required: field.required,
18533
+ span: field.span,
18534
+ translationFn,
18535
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18536
+ type: field.inputType ?? "text",
18537
+ className: INPUT_CLASS,
18538
+ value: value === void 0 || value === null ? "" : String(value),
18539
+ placeholder: field.placeholder,
18540
+ maxLength: field.maxLength,
18541
+ pattern: field.pattern,
18542
+ disabled: disabled || field.disabled,
18543
+ onChange: (e) => onChange(e.target.value)
18544
+ })
18545
+ });
18546
+ }
18547
+ function NumberField({ field, value, onChange, disabled, translationFn }) {
18548
+ const [local, setLocal] = (0, react$1.useState)(value === void 0 || value === null ? "" : String(value));
18549
+ const focusedRef = (0, react$1.useRef)(false);
18550
+ (0, react$1.useEffect)(() => {
18551
+ if (focusedRef.current) return;
18552
+ setLocal(value === void 0 || value === null ? "" : String(value));
18553
+ }, [value]);
18554
+ const handleChange = (raw) => {
18555
+ setLocal(raw);
18556
+ if (raw === "" || raw === "-") {
18557
+ onChange(void 0);
18558
+ return;
18559
+ }
18560
+ const parsed = Number(raw);
18561
+ if (Number.isNaN(parsed)) return;
18562
+ onChange(parsed);
18563
+ };
18564
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldWrapper, {
18565
+ label: field.label,
18566
+ description: field.description,
18567
+ required: field.required,
18568
+ span: field.span,
18569
+ translationFn,
18570
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18571
+ className: "flex items-center gap-1",
18572
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18573
+ type: "number",
18574
+ className: INPUT_CLASS,
18575
+ value: local,
18576
+ placeholder: field.placeholder,
18577
+ min: field.min,
18578
+ max: field.max,
18579
+ step: field.step,
18580
+ disabled: disabled || field.disabled,
18581
+ onFocus: () => {
18582
+ focusedRef.current = true;
18583
+ },
18584
+ onBlur: () => {
18585
+ focusedRef.current = false;
18586
+ },
18587
+ onChange: (e) => handleChange(e.target.value)
18588
+ }), field.unit && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18589
+ className: "text-xs text-foreground-subtle whitespace-nowrap",
17165
18590
  children: field.unit
17166
18591
  })]
17167
18592
  })
@@ -18270,18 +19695,12 @@ function AddonActionButtonField({ field, values, disabled, onAction }) {
18270
19695
  }
18271
19696
  function WidgetField({ field }) {
18272
19697
  const deviceId = useDeviceId();
18273
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldWrapper, {
18274
- label: field.label,
18275
- description: field.description,
18276
- required: field.required,
18277
- span: field.span,
18278
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetSlot, {
18279
- widgetId: field.widgetId,
18280
- host: "device-tab",
18281
- config: field.widgetConfig,
18282
- deviceId: deviceId ?? void 0,
18283
- instanceId: field.key
18284
- })
19698
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidgetSlot, {
19699
+ widgetId: field.widgetId,
19700
+ host: "device-tab",
19701
+ config: field.widgetConfig,
19702
+ deviceId: deviceId ?? void 0,
19703
+ instanceId: field.key
18285
19704
  });
18286
19705
  }
18287
19706
  function formatReadonlyValue(value, unit) {
@@ -18772,7 +20191,7 @@ function ConfigFormBuilder({ schema, values, onChange, disabled, translationFn,
18772
20191
  * + patch loop. `onAfterChange` fires once the patch is applied so
18773
20192
  * consumers can re-read any derived state.
18774
20193
  */
18775
- var Chevron = ({ open }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
20194
+ var Chevron$1 = ({ open }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
18776
20195
  className: `h-3 w-3 transition-transform ${open ? "rotate-90" : ""}`,
18777
20196
  viewBox: "0 0 24 24",
18778
20197
  fill: "none",
@@ -18937,7 +20356,7 @@ function AddonGlobalSettingsForm({ trpc, addonId, nodeId, title, disabled, onAft
18937
20356
  className: "w-full px-4 py-2 flex items-center gap-2 hover:bg-surface-hover text-left",
18938
20357
  "aria-expanded": open,
18939
20358
  children: [
18940
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chevron, { open }),
20359
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chevron$1, { open }),
18941
20360
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
18942
20361
  className: "text-xs font-semibold text-foreground uppercase tracking-wide flex-1",
18943
20362
  children: title ?? addonId
@@ -19507,184 +20926,38 @@ function CameraStreamPlayer({ serverUrl, streamKey, label, autoPlay = true, mute
19507
20926
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
19508
20927
  className: "h-3.5 w-3.5",
19509
20928
  viewBox: "0 0 24 24",
19510
- fill: "none",
19511
- stroke: "currentColor",
19512
- strokeWidth: "2",
19513
- strokeLinecap: "round",
19514
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" })
19515
- })
19516
- })]
19517
- })]
19518
- })
19519
- ]
19520
- });
19521
- }
19522
- function ToolbarButton$1({ onClick, title, children }) {
19523
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
19524
- onClick,
19525
- title,
19526
- className: "rounded-full p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition-colors",
19527
- children
19528
- });
19529
- }
19530
- async function waitForIceGathering(pc) {
19531
- if (pc.iceGatheringState === "complete") return;
19532
- return new Promise((resolve) => {
19533
- const handler = () => {
19534
- if (pc.iceGatheringState === "complete") {
19535
- pc.removeEventListener("icegatheringstatechange", handler);
19536
- resolve();
19537
- }
19538
- };
19539
- pc.addEventListener("icegatheringstatechange", handler);
19540
- setTimeout(resolve, 5e3);
19541
- });
19542
- }
19543
- //#endregion
19544
- //#region src/contexts/player-overlays.tsx
19545
- /**
19546
- * Player overlay registry — pluggable layers + toolbar buttons for
19547
- * the device-detail live-frame `StreamPanel`.
19548
- *
19549
- * Why a registry: previously `StreamPanelContent` hardcoded each
19550
- * overlay (PTZ, intercom toggle, zone editor) and threaded the
19551
- * editing state by hand. Adding a new feature (audio waveform,
19552
- * detection bbox tracker, recording controls, …) meant another
19553
- * round of plumbing through the host file and StreamPanel's prop
19554
- * surface. The registry lets a sibling component (Detection tab,
19555
- * Live Stats tab, an addon-page extension) declare its own layer
19556
- * imperatively via a hook, and the host renders whatever's
19557
- * registered.
19558
- *
19559
- * Two registries live side-by-side:
19560
- *
19561
- * 1. **layers** — absolute-positioned React nodes drawn over the
19562
- * video frame (zones polygon canvas, motion bbox overlay,
19563
- * audio waveform). Rendered ordered by `order` (lower first
19564
- * → bottom; higher → top); the last-registered layer wins
19565
- * ties.
19566
- *
19567
- * 2. **toolbar buttons** — controls surfaced in the player's
19568
- * always-visible toolbar cluster (next to Intercom + Play/Stop).
19569
- * Each carries an icon, label, controlled `active` flag, and
19570
- * a click handler. The host (StreamPanel) renders them
19571
- * uniformly; tone variants stay simple (`'default' | 'primary'`).
19572
- *
19573
- * Lifecycle: hooks register their layer / button on mount and
19574
- * unregister on unmount, so swapping tabs (e.g. leaving Detection)
19575
- * automatically tears down the registration. Re-registering with
19576
- * the same `id` replaces the prior entry — meaning a single hook
19577
- * call can update its overlay/button props on every render without
19578
- * leaking entries.
19579
- *
19580
- * Provider scope: typically wraps the whole device-detail subtree
19581
- * so the StreamPanel + every tab share the same registry. One
19582
- * provider per `deviceId`; switching device IDs unmounts the
19583
- * provider naturally.
19584
- */
19585
- var PlayerOverlaysStateContext = createSharedContext("camstack:player-overlays-state", null);
19586
- var PlayerOverlaysActionsContext = createSharedContext("camstack:player-overlays-actions", null);
19587
- function PlayerOverlaysProvider({ children }) {
19588
- const [layers, setLayers] = (0, react$1.useState)(() => /* @__PURE__ */ new Map());
19589
- const [buttons, setButtons] = (0, react$1.useState)(() => /* @__PURE__ */ new Map());
19590
- const setLayer = (0, react$1.useCallback)((layer) => {
19591
- setLayers((prev) => {
19592
- const next = new Map(prev);
19593
- next.set(layer.id, layer);
19594
- return next;
19595
- });
19596
- }, []);
19597
- const removeLayer = (0, react$1.useCallback)((id) => {
19598
- setLayers((prev) => {
19599
- if (!prev.has(id)) return prev;
19600
- const next = new Map(prev);
19601
- next.delete(id);
19602
- return next;
19603
- });
19604
- }, []);
19605
- const setButton = (0, react$1.useCallback)((button) => {
19606
- setButtons((prev) => {
19607
- const next = new Map(prev);
19608
- next.set(button.id, button);
19609
- return next;
19610
- });
19611
- }, []);
19612
- const removeButton = (0, react$1.useCallback)((id) => {
19613
- setButtons((prev) => {
19614
- if (!prev.has(id)) return prev;
19615
- const next = new Map(prev);
19616
- next.delete(id);
19617
- return next;
19618
- });
19619
- }, []);
19620
- const stateValue = (0, react$1.useMemo)(() => ({
19621
- layers,
19622
- buttons
19623
- }), [layers, buttons]);
19624
- const actionsValue = (0, react$1.useMemo)(() => ({
19625
- setLayer,
19626
- removeLayer,
19627
- setButton,
19628
- removeButton
19629
- }), [
19630
- setLayer,
19631
- removeLayer,
19632
- setButton,
19633
- removeButton
19634
- ]);
19635
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayerOverlaysStateContext.Provider, {
19636
- value: stateValue,
19637
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlayerOverlaysActionsContext.Provider, {
19638
- value: actionsValue,
19639
- children
19640
- })
20929
+ fill: "none",
20930
+ stroke: "currentColor",
20931
+ strokeWidth: "2",
20932
+ strokeLinecap: "round",
20933
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" })
20934
+ })
20935
+ })]
20936
+ })]
20937
+ })
20938
+ ]
19641
20939
  });
19642
20940
  }
19643
- /** Snapshot of registered layers ordered by `order` (asc, ties resolved
19644
- * by insertion order of the underlying Map). Returns `[]` outside a
19645
- * provider. */
19646
- function usePlayerOverlayLayers() {
19647
- const state = (0, react$1.useContext)(PlayerOverlaysStateContext);
19648
- return (0, react$1.useMemo)(() => {
19649
- if (!state) return [];
19650
- return [...state.layers.values()].sort((a, b) => a.order - b.order);
19651
- }, [state]);
19652
- }
19653
- /** Snapshot of registered toolbar buttons, ordered by `order` (asc). */
19654
- function usePlayerToolbarButtons() {
19655
- const state = (0, react$1.useContext)(PlayerOverlaysStateContext);
19656
- return (0, react$1.useMemo)(() => {
19657
- if (!state) return [];
19658
- return [...state.buttons.values()].sort((a, b) => a.order - b.order);
19659
- }, [state]);
19660
- }
19661
- /**
19662
- * Register an overlay layer for the lifetime of the calling component.
19663
- * Re-registers on every render with the latest spec; auto-unregisters
19664
- * on unmount. Pass `null` to skip registration when the layer is
19665
- * conditionally enabled (the hook still runs every render — keeps
19666
- * react-hook order stable across spec === null toggles).
19667
- *
19668
- * Callers that build the spec inline should memoise it (`useMemo`)
19669
- * to avoid re-registering on every parent render — context-write
19670
- * effects depend on referential equality of the spec object.
19671
- */
19672
- function usePlayerOverlayLayer(spec) {
19673
- const actions = (0, react$1.useContext)(PlayerOverlaysActionsContext);
19674
- (0, react$1.useEffect)(() => {
19675
- if (!actions || !spec) return void 0;
19676
- actions.setLayer(spec);
19677
- return () => actions.removeLayer(spec.id);
19678
- }, [actions, spec]);
20941
+ function ToolbarButton$1({ onClick, title, children }) {
20942
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
20943
+ onClick,
20944
+ title,
20945
+ className: "rounded-full p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition-colors",
20946
+ children
20947
+ });
19679
20948
  }
19680
- /** Same shape as `usePlayerOverlayLayer`, scoped to toolbar buttons. */
19681
- function usePlayerToolbarButton(spec) {
19682
- const actions = (0, react$1.useContext)(PlayerOverlaysActionsContext);
19683
- (0, react$1.useEffect)(() => {
19684
- if (!actions || !spec) return void 0;
19685
- actions.setButton(spec);
19686
- return () => actions.removeButton(spec.id);
19687
- }, [actions, spec]);
20949
+ async function waitForIceGathering(pc) {
20950
+ if (pc.iceGatheringState === "complete") return;
20951
+ return new Promise((resolve) => {
20952
+ const handler = () => {
20953
+ if (pc.iceGatheringState === "complete") {
20954
+ pc.removeEventListener("icegatheringstatechange", handler);
20955
+ resolve();
20956
+ }
20957
+ };
20958
+ pc.addEventListener("icegatheringstatechange", handler);
20959
+ setTimeout(resolve, 5e3);
20960
+ });
19688
20961
  }
19689
20962
  //#endregion
19690
20963
  //#region src/composites/stream-panel.tsx
@@ -22170,7 +23443,7 @@ var LEVEL_OPTIONS = [
22170
23443
  "error"
22171
23444
  ];
22172
23445
  /** Max live entries kept in the ring buffer. */
22173
- var MAX_LIVE_ENTRIES$2 = 500;
23446
+ var MAX_LIVE_ENTRIES$1 = 500;
22174
23447
  function passesLevel(logLevel, filterLevel) {
22175
23448
  if (!filterLevel) return true;
22176
23449
  return (LEVEL_SEVERITY[logLevel] ?? 0) >= (LEVEL_SEVERITY[filterLevel] ?? 0);
@@ -22199,7 +23472,7 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
22199
23472
  integrationId,
22200
23473
  requestId
22201
23474
  ]);
22202
- const fallbackBuffer = useLiveBuffer(MAX_LIVE_ENTRIES$2);
23475
+ const fallbackBuffer = useLiveBuffer(MAX_LIVE_ENTRIES$1);
22203
23476
  const buffer = externalBuffer ?? fallbackBuffer;
22204
23477
  const liveLogs = buffer.entries;
22205
23478
  const [clearedAt, setClearedAt] = (0, react$1.useState)(0);
@@ -22526,7 +23799,7 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
22526
23799
  }),
22527
23800
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
22528
23801
  className: "px-1 py-1 align-top w-5",
22529
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCopyButton$2, {
23802
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCopyButton$1, {
22530
23803
  copied: copiedRowKey === key,
22531
23804
  onCopy: () => copyOne(log, key)
22532
23805
  })
@@ -22565,7 +23838,7 @@ function ScopeBadge$2({ label, value }) {
22565
23838
  * "did it work" affordance — same UX language the toolbar Copy
22566
23839
  * button already uses for its `Copied!` label.
22567
23840
  */
22568
- function RowCopyButton$2({ copied, onCopy }) {
23841
+ function RowCopyButton$1({ copied, onCopy }) {
22569
23842
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
22570
23843
  type: "button",
22571
23844
  title: "Copy this row",
@@ -22890,7 +24163,7 @@ function hasContent(entry) {
22890
24163
  if (cat === _camstack_types.EventCategory.DetectionResult || cat === _camstack_types.EventCategory.PipelineAudioInferenceResult) return summarizeEvent(cat, entry.data) !== null;
22891
24164
  return true;
22892
24165
  }
22893
- var MAX_LIVE_ENTRIES$1 = 300;
24166
+ var MAX_LIVE_ENTRIES = 300;
22894
24167
  function EventStream({ agentId, addonId, deviceId, defaultCategories, categories: legacyCategories, category: propsCategory, maxHeight = "max-h-96", limit = 50, showCategoryFilter = true, showFilters: _showFilters = false, liveBuffer: externalBuffer, onClose, className }) {
22895
24168
  const defaultsArray = (0, react$1.useMemo)(() => defaultCategories ?? legacyCategories ?? DEFAULT_EVENT_CATEGORIES, [defaultCategories, legacyCategories]);
22896
24169
  const defaultsKey = (0, react$1.useMemo)(() => [...defaultsArray].sort().join(","), [defaultsArray]);
@@ -22902,7 +24175,7 @@ function EventStream({ agentId, addonId, deviceId, defaultCategories, categories
22902
24175
  setActiveCategories(new Set(defaultsArray));
22903
24176
  }
22904
24177
  }, [defaultsKey, defaultsArray]);
22905
- const fallbackBuffer = useLiveBuffer(MAX_LIVE_ENTRIES$1);
24178
+ const fallbackBuffer = useLiveBuffer(MAX_LIVE_ENTRIES);
22906
24179
  const buffer = externalBuffer ?? fallbackBuffer;
22907
24180
  const liveEvents = buffer.entries;
22908
24181
  const [autoScroll, setAutoScroll] = (0, react$1.useState)(true);
@@ -23226,7 +24499,7 @@ function EventStream({ agentId, addonId, deviceId, defaultCategories, categories
23226
24499
  className: "text-foreground-subtle whitespace-nowrap w-[70px] shrink-0 pt-0.5",
23227
24500
  children: new Date(event.timestamp).toLocaleTimeString()
23228
24501
  }),
23229
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCopyButton$1, {
24502
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCopyButton, {
23230
24503
  copied: copiedRowId === event.id,
23231
24504
  onCopy: () => copyOne(event)
23232
24505
  }),
@@ -23416,7 +24689,7 @@ function ScopeBadge$1({ label, value }) {
23416
24689
  * click doesn't toggle the row's expand state. Flashes a Check
23417
24690
  * for ~1s after copy.
23418
24691
  */
23419
- function RowCopyButton$1({ copied, onCopy }) {
24692
+ function RowCopyButton({ copied, onCopy }) {
23420
24693
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
23421
24694
  type: "button",
23422
24695
  title: "Copy this event",
@@ -23683,162 +24956,137 @@ function StatusBadge$1({ status }) {
23683
24956
  //#endregion
23684
24957
  //#region src/composites/state-values-stream.tsx
23685
24958
  /**
23686
- * StateValuesStream — runtime-state change viewer for ui-library.
24959
+ * StateValuesStream — live current-state tree for ui-library.
24960
+ *
24961
+ * Renders a single device's CURRENT runtime-state as a scrollable,
24962
+ * collapsible hierarchical tree (NOT a chronological change log).
23687
24963
  *
23688
- * Subscribes to `EventCategory.DeviceStateChanged` events scoped to a
23689
- * single device. Each row shows: timestamp, capName, slice JSON.
23690
- * Supports cap-name multiselect filter + free-text search.
24964
+ * - Seed: on mount, `trpc.deviceState.getAllSnapshots` returns the
24965
+ * whole-system snapshot; we pick out this device's entry and build
24966
+ * a `Map<capName, slice>` of its current state.
24967
+ * - Realtime: an `EventCategory.DeviceStateChanged` tRPC subscription
24968
+ * scoped to the device patches each cap's slice into the map in
24969
+ * place — the matching tree node re-renders, no rows are appended.
24970
+ * - Tree: top level = cap names (sorted, expanded by default); each
24971
+ * cap node expands to its slice keys; nested objects/arrays expand
24972
+ * recursively, deep nesting collapsed by default. A per-cap
24973
+ * "updated Ns ago" indicator surfaces realtime activity.
24974
+ * - Filter/search: cap-name multiselect popover + free-text search,
24975
+ * both applied to the tree (filter which caps show; search matches
24976
+ * keys/values anywhere in the slice).
23691
24977
  *
23692
- * Live subscription is mounted only while this component is rendered,
24978
+ * The live subscription is mounted only while this component renders,
23693
24979
  * so the parent can mount/unmount it on tab switches and the tRPC
23694
24980
  * subscription is torn down automatically when hidden.
23695
24981
  */
23696
- function isStateChangeForDevice(raw, deviceId) {
23697
- if (!raw || typeof raw !== "object") return false;
23698
- const e = raw;
23699
- if (e.category !== _camstack_types.EventCategory.DeviceStateChanged) return false;
23700
- const data = e.data;
23701
- if (!data || typeof data !== "object") return false;
23702
- if (data.deviceId !== deviceId) return false;
23703
- if (typeof data.capName !== "string") return false;
23704
- if (!data.slice || typeof data.slice !== "object") return false;
23705
- return true;
23706
- }
23707
- function toEntry(raw) {
24982
+ /** Narrow an untrusted live-stream event to a `DeviceStateChanged`
24983
+ * for THIS device, returning the cap name + slice it carries. */
24984
+ function readStateChange(raw, deviceId) {
23708
24985
  if (!raw || typeof raw !== "object") return null;
23709
24986
  const e = raw;
23710
- const id = typeof e.id === "string" ? e.id : null;
23711
- const timestamp = typeof e.timestamp === "string" ? e.timestamp : null;
24987
+ if (e.category !== _camstack_types.EventCategory.DeviceStateChanged) return null;
23712
24988
  const data = e.data;
23713
- if (!id || !timestamp || !data || typeof data !== "object") return null;
23714
- if (typeof data.capName !== "string" || !data.slice || typeof data.slice !== "object") return null;
24989
+ if (!data || typeof data !== "object") return null;
24990
+ if (data.deviceId !== deviceId) return null;
24991
+ if (typeof data.capName !== "string") return null;
24992
+ if (!data.slice || typeof data.slice !== "object") return null;
23715
24993
  return {
23716
- id,
23717
- timestamp,
23718
24994
  capName: data.capName,
23719
24995
  slice: data.slice
23720
24996
  };
23721
24997
  }
23722
- var MAX_LIVE_ENTRIES = 300;
24998
+ function isSystemSnapshot(raw) {
24999
+ return !!raw && typeof raw === "object" && !Array.isArray(raw);
25000
+ }
23723
25001
  /**
23724
25002
  * Every device-scoped cap name, sorted alphabetically — same shape
23725
25003
  * as `ALL_EVENT_CATEGORIES` in event-stream.tsx so the filter
23726
25004
  * surface is symmetric across the three live-streams (Logs, Events,
23727
- * State). Computed once at module load; the user can pick from this
23728
- * full list rather than only the caps that have already emitted at
23729
- * least one slice change in the current session.
25005
+ * State). Computed once at module load.
23730
25006
  */
23731
25007
  var ALL_DEVICE_CAP_NAMES = Object.freeze([...new Set(_camstack_types.ALL_CAPABILITY_DEFINITIONS.filter((c) => c.scope === "device").map((c) => c.name))].sort());
23732
- function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limit = 50, liveBuffer: externalBuffer, onClose, className }) {
25008
+ /** Depth at which tree nodes start collapsed. Cap nodes (depth 0) and
25009
+ * their direct slice keys (depth 1) render expanded; anything nested
25010
+ * deeper opens on demand. */
25011
+ var DEFAULT_EXPAND_DEPTH = 1;
25012
+ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", onClose, className }) {
23733
25013
  const [activeCaps, setActiveCaps] = (0, react$1.useState)((0, react$1.useMemo)(() => new Set(defaultCaps ?? []), [defaultCaps]));
23734
25014
  const [searchText, setSearchText] = (0, react$1.useState)("");
23735
- const [autoScroll, setAutoScroll] = (0, react$1.useState)(true);
23736
- const [expandedRows, setExpandedRows] = (0, react$1.useState)(/* @__PURE__ */ new Set());
23737
- const [clearedAt, setClearedAt] = (0, react$1.useState)(0);
23738
25015
  const [filterOpen, setFilterOpen] = (0, react$1.useState)(false);
23739
25016
  const [filterSearch, setFilterSearch] = (0, react$1.useState)("");
23740
25017
  const filterRootRef = (0, react$1.useRef)(null);
23741
- const fallbackBuffer = useLiveBuffer(MAX_LIVE_ENTRIES);
23742
- const buffer = externalBuffer ?? fallbackBuffer;
23743
- const liveEvents = buffer.entries;
23744
- const scrollRef = (0, react$1.useRef)(null);
25018
+ const [capStates, setCapStates] = (0, react$1.useState)(/* @__PURE__ */ new Map());
25019
+ const [collapseOverrides, setCollapseOverrides] = (0, react$1.useState)(/* @__PURE__ */ new Map());
25020
+ const [, setNowTick] = (0, react$1.useState)(0);
23745
25021
  const prevDeviceRef = (0, react$1.useRef)(deviceId);
23746
25022
  (0, react$1.useEffect)(() => {
23747
25023
  if (prevDeviceRef.current !== deviceId) {
23748
25024
  prevDeviceRef.current = deviceId;
23749
- buffer.reset();
23750
- setExpandedRows(/* @__PURE__ */ new Set());
23751
- setClearedAt(0);
25025
+ setCapStates(/* @__PURE__ */ new Map());
25026
+ setCollapseOverrides(/* @__PURE__ */ new Map());
23752
25027
  }
23753
- }, [deviceId, buffer]);
23754
- const recentInput = (0, react$1.useMemo)(() => ({
23755
- deviceId,
23756
- category: _camstack_types.EventCategory.DeviceStateChanged,
23757
- limit
23758
- }), [deviceId, limit]);
23759
- const { data: initialEvents, isLoading } = trpc.systemEvents.getRecent.useQuery(recentInput, { staleTime: 3e4 });
23760
- const activeCapsRef = (0, react$1.useRef)(activeCaps);
25028
+ }, [deviceId]);
25029
+ const { data: snapshot, isLoading } = trpc.deviceState.getAllSnapshots.useQuery({}, { staleTime: 3e4 });
25030
+ const seededRef = (0, react$1.useRef)(false);
25031
+ (0, react$1.useEffect)(() => {
25032
+ if (seededRef.current) return;
25033
+ if (!isSystemSnapshot(snapshot)) return;
25034
+ const deviceSlices = snapshot[String(deviceId)];
25035
+ seededRef.current = true;
25036
+ if (!deviceSlices) return;
25037
+ const now = Date.now();
25038
+ setCapStates((prev) => {
25039
+ const next = new Map(prev);
25040
+ for (const [capName, slice] of Object.entries(deviceSlices)) {
25041
+ if (next.has(capName)) continue;
25042
+ next.set(capName, {
25043
+ slice,
25044
+ updatedAt: now
25045
+ });
25046
+ }
25047
+ return next;
25048
+ });
25049
+ }, [snapshot, deviceId]);
23761
25050
  (0, react$1.useEffect)(() => {
23762
- activeCapsRef.current = activeCaps;
23763
- }, [activeCaps]);
25051
+ seededRef.current = false;
25052
+ }, [deviceId]);
23764
25053
  trpc.systemEvents.subscribe.useSubscription({
23765
25054
  deviceId,
23766
25055
  category: _camstack_types.EventCategory.DeviceStateChanged
23767
25056
  }, { onData: (raw) => {
23768
- if (!isStateChangeForDevice(raw, deviceId)) return;
23769
- const entry = toEntry(raw);
23770
- if (!entry) return;
23771
- const active = activeCapsRef.current;
23772
- if (active.size > 0 && !active.has(entry.capName)) return;
23773
- buffer.append(entry);
25057
+ const change = readStateChange(raw, deviceId);
25058
+ if (!change) return;
25059
+ setCapStates((prev) => {
25060
+ const next = new Map(prev);
25061
+ next.set(change.capName, {
25062
+ slice: change.slice,
25063
+ updatedAt: Date.now()
25064
+ });
25065
+ return next;
25066
+ });
23774
25067
  } });
23775
- const prevLiveCountRef = (0, react$1.useRef)(liveEvents.length);
23776
25068
  (0, react$1.useEffect)(() => {
23777
- const el = scrollRef.current;
23778
- if (!el) {
23779
- prevLiveCountRef.current = liveEvents.length;
23780
- return;
23781
- }
23782
- const prevCount = prevLiveCountRef.current;
23783
- prevLiveCountRef.current = liveEvents.length;
23784
- if (liveEvents.length <= prevCount) return;
23785
- if (autoScroll && el.scrollTop <= 8) el.scrollTo({ top: 0 });
23786
- else if (!autoScroll && el.scrollTop > 0) {
23787
- const firstRow = el.firstElementChild?.firstElementChild;
23788
- const rowHeight = firstRow instanceof HTMLElement ? firstRow.offsetHeight : 28;
23789
- const newCount = liveEvents.length - prevCount;
23790
- el.scrollTop += rowHeight * newCount;
23791
- }
23792
- }, [liveEvents.length, autoScroll]);
23793
- const allEntries = (0, react$1.useMemo)(() => {
23794
- const byId = /* @__PURE__ */ new Map();
23795
- for (const e of initialEvents ?? []) {
23796
- const entry = toEntry(e);
23797
- if (entry) byId.set(entry.id, entry);
23798
- }
23799
- for (const e of liveEvents) byId.set(e.id, e);
23800
- let list = [...byId.values()];
23801
- if (clearedAt > 0) list = list.filter((e) => new Date(e.timestamp).getTime() > clearedAt);
23802
- if (activeCaps.size > 0) list = list.filter((e) => activeCaps.has(e.capName));
25069
+ const id = setInterval(() => setNowTick((t) => t + 1), 5e3);
25070
+ return () => clearInterval(id);
25071
+ }, []);
25072
+ const visibleCaps = (0, react$1.useMemo)(() => {
25073
+ const caps = new Set(ALL_DEVICE_CAP_NAMES);
25074
+ for (const capName of capStates.keys()) caps.add(capName);
25075
+ return [...caps].sort();
25076
+ }, [capStates]);
25077
+ const filteredCapNames = (0, react$1.useMemo)(() => {
23803
25078
  const needle = searchText.trim().toLowerCase();
23804
- if (needle) list = list.filter((e) => {
23805
- if (e.capName.toLowerCase().includes(needle)) return true;
23806
- try {
23807
- if (new Date(e.timestamp).toLocaleTimeString().toLowerCase().includes(needle)) return true;
23808
- } catch {}
23809
- try {
23810
- if (Object.entries(e.slice).map(([k, v]) => `${k}=${formatValue(v)}`).join(" · ").toLowerCase().includes(needle)) return true;
23811
- } catch {}
23812
- try {
23813
- if (JSON.stringify(e.slice).toLowerCase().includes(needle)) return true;
23814
- } catch {}
23815
- return false;
25079
+ return [...capStates.keys()].sort().filter((capName) => {
25080
+ if (activeCaps.size > 0 && !activeCaps.has(capName)) return false;
25081
+ if (!needle) return true;
25082
+ if (capName.toLowerCase().includes(needle)) return true;
25083
+ return sliceMatchesSearch(capStates.get(capName)?.slice ?? {}, needle);
23816
25084
  });
23817
- return list.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
23818
25085
  }, [
23819
- initialEvents,
23820
- liveEvents,
25086
+ capStates,
23821
25087
  activeCaps,
23822
- searchText,
23823
- clearedAt
25088
+ searchText
23824
25089
  ]);
23825
- const visibleCaps = (0, react$1.useMemo)(() => {
23826
- const caps = new Set(ALL_DEVICE_CAP_NAMES);
23827
- for (const e of initialEvents ?? []) {
23828
- const entry = toEntry(e);
23829
- if (entry) caps.add(entry.capName);
23830
- }
23831
- for (const e of liveEvents) caps.add(e.capName);
23832
- return [...caps].sort();
23833
- }, [initialEvents, liveEvents]);
23834
- const toggleRow = (0, react$1.useCallback)((id) => {
23835
- setExpandedRows((prev) => {
23836
- const next = new Set(prev);
23837
- if (next.has(id)) next.delete(id);
23838
- else next.add(id);
23839
- return next;
23840
- });
23841
- }, []);
23842
25090
  const toggleCap = (0, react$1.useCallback)((cap) => {
23843
25091
  setActiveCaps((prev) => {
23844
25092
  const next = new Set(prev);
@@ -23851,34 +25099,36 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23851
25099
  setActiveCaps(new Set(defaultCaps ?? []));
23852
25100
  setSearchText("");
23853
25101
  }, [defaultCaps]);
23854
- const handleClear = (0, react$1.useCallback)(() => {
23855
- setClearedAt(Date.now());
23856
- buffer.reset();
23857
- }, [buffer]);
23858
- const [copiedAll, setCopiedAll] = (0, react$1.useState)(false);
23859
- const [copiedRowId, setCopiedRowId] = (0, react$1.useState)(null);
23860
- const formatEntryForCopy = (0, react$1.useCallback)((entry) => {
23861
- return `${new Date(entry.timestamp).toISOString()} [${entry.capName}] slice=${JSON.stringify(entry.slice)}`;
23862
- }, []);
23863
- const handleCopyAll = (0, react$1.useCallback)(() => {
23864
- const lines = allEntries.map(formatEntryForCopy);
23865
- navigator.clipboard.writeText(lines.join("\n")).then(() => {
23866
- setCopiedAll(true);
23867
- setTimeout(() => setCopiedAll(false), 1500);
23868
- });
23869
- }, [allEntries, formatEntryForCopy]);
23870
- const copyOne = (0, react$1.useCallback)((entry) => {
23871
- navigator.clipboard.writeText(formatEntryForCopy(entry)).then(() => {
23872
- setCopiedRowId(entry.id);
23873
- setTimeout(() => setCopiedRowId((prev) => prev === entry.id ? null : prev), 1200);
23874
- });
23875
- }, [formatEntryForCopy]);
23876
25102
  const handleSelectAllCaps = (0, react$1.useCallback)(() => {
23877
25103
  setActiveCaps(new Set(visibleCaps));
23878
25104
  }, [visibleCaps]);
23879
25105
  const handleSelectNoCaps = (0, react$1.useCallback)(() => {
23880
25106
  setActiveCaps(/* @__PURE__ */ new Set());
23881
25107
  }, []);
25108
+ const toggleNode = (0, react$1.useCallback)((path, currentlyOpen) => {
25109
+ setCollapseOverrides((prev) => {
25110
+ const next = new Map(prev);
25111
+ next.set(path, !currentlyOpen);
25112
+ return next;
25113
+ });
25114
+ }, []);
25115
+ const isPathOpen = (0, react$1.useCallback)((path, depth) => {
25116
+ const override = collapseOverrides.get(path);
25117
+ if (override !== void 0) return override;
25118
+ return depth < DEFAULT_EXPAND_DEPTH;
25119
+ }, [collapseOverrides]);
25120
+ const [copiedAll, setCopiedAll] = (0, react$1.useState)(false);
25121
+ const handleCopyAll = (0, react$1.useCallback)(() => {
25122
+ const out = {};
25123
+ for (const capName of filteredCapNames) {
25124
+ const state = capStates.get(capName);
25125
+ if (state) out[capName] = state.slice;
25126
+ }
25127
+ navigator.clipboard.writeText(JSON.stringify(out, null, 2)).then(() => {
25128
+ setCopiedAll(true);
25129
+ setTimeout(() => setCopiedAll(false), 1500);
25130
+ });
25131
+ }, [filteredCapNames, capStates]);
23882
25132
  const filteredPopoverCaps = (0, react$1.useMemo)(() => {
23883
25133
  const needle = filterSearch.trim().toLowerCase();
23884
25134
  if (!needle) return visibleCaps;
@@ -23897,6 +25147,7 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23897
25147
  document.addEventListener("mousedown", onDocClick);
23898
25148
  return () => document.removeEventListener("mousedown", onDocClick);
23899
25149
  }, [filterOpen]);
25150
+ const searchNeedle = searchText.trim().toLowerCase();
23900
25151
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
23901
25152
  className: cn("h-full min-h-0 flex flex-col", className),
23902
25153
  onClick: (e) => e.stopPropagation(),
@@ -23913,18 +25164,6 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23913
25164
  label: "device",
23914
25165
  value: `#${deviceId}`
23915
25166
  }),
23916
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
23917
- type: "button",
23918
- onClick: () => {
23919
- setAutoScroll((a) => !a);
23920
- if (!autoScroll && scrollRef.current) scrollRef.current.scrollTo({
23921
- top: 0,
23922
- behavior: "smooth"
23923
- });
23924
- },
23925
- className: cn("inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border transition-colors", autoScroll ? "border-border text-foreground-subtle hover:text-foreground" : "border-amber-500/30 bg-amber-500/10 text-amber-400"),
23926
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ArrowUpToLine, { className: "h-2.5 w-2.5" }), autoScroll ? "Auto" : "Paused"]
23927
- }),
23928
25167
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
23929
25168
  className: "relative",
23930
25169
  ref: filterRootRef,
@@ -23932,7 +25171,7 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23932
25171
  type: "button",
23933
25172
  onClick: () => setFilterOpen((v) => !v),
23934
25173
  className: cn("inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border transition-colors", !allSelected ? "border-violet-500/30 bg-violet-500/10 text-violet-400" : "border-border text-foreground-subtle hover:text-foreground"),
23935
- title: "Pick caps to track",
25174
+ title: "Pick caps to show",
23936
25175
  children: [
23937
25176
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Funnel, { className: "h-2.5 w-2.5" }),
23938
25177
  "Filter (",
@@ -23957,19 +25196,12 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23957
25196
  title: "Reset filters",
23958
25197
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RotateCcw, { className: "h-2.5 w-2.5" }), "Reset"]
23959
25198
  }),
23960
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
23961
- type: "button",
23962
- onClick: handleClear,
23963
- className: "inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border border-border text-foreground-subtle hover:text-foreground transition-colors",
23964
- title: "Clear visible entries",
23965
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Trash2, { className: "h-2.5 w-2.5" }), "Clear"]
23966
- }),
23967
25199
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
23968
25200
  type: "button",
23969
25201
  onClick: handleCopyAll,
23970
- disabled: allEntries.length === 0,
23971
- className: cn("inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border transition-colors", copiedAll ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" : "border-border text-foreground-subtle hover:text-foreground", allEntries.length === 0 && "opacity-40 cursor-not-allowed"),
23972
- title: "Copy all visible state changes",
25202
+ disabled: filteredCapNames.length === 0,
25203
+ className: cn("inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border transition-colors", copiedAll ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" : "border-border text-foreground-subtle hover:text-foreground", filteredCapNames.length === 0 && "opacity-40 cursor-not-allowed"),
25204
+ title: "Copy visible state as JSON",
23973
25205
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-2.5 w-2.5" }), copiedAll ? "Copied!" : "Copy"]
23974
25206
  }),
23975
25207
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -23988,13 +25220,9 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
23988
25220
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-2.5 w-2.5" })
23989
25221
  })]
23990
25222
  }),
23991
- liveEvents.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
23992
- className: "text-[9px] text-emerald-400 font-medium",
23993
- children: [
23994
- "+",
23995
- liveEvents.length,
23996
- " live"
23997
- ]
25223
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
25224
+ className: "text-[9px] text-emerald-400 font-medium inline-flex items-center gap-1",
25225
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" }), "live"]
23998
25226
  })
23999
25227
  ]
24000
25228
  }), onClose && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -24004,90 +25232,172 @@ function StateValuesStream({ deviceId, defaultCaps, maxHeight = "max-h-96", limi
24004
25232
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(X, { className: "h-3.5 w-3.5" })
24005
25233
  })]
24006
25234
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24007
- ref: scrollRef,
24008
- className: cn("flex-1 min-h-0 overflow-auto text-[10px]", maxHeight),
25235
+ className: cn("flex-1 min-h-0 overflow-auto text-[10px] font-mono", maxHeight),
24009
25236
  children: [
24010
- isLoading && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24011
- className: "flex items-center justify-center gap-2 py-6 text-foreground-subtle",
24012
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading state changes..."]
25237
+ isLoading && capStates.size === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25238
+ className: "flex items-center justify-center gap-2 py-6 text-foreground-subtle font-sans",
25239
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), "Loading device state..."]
24013
25240
  }),
24014
- !isLoading && allEntries.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24015
- className: "py-4 text-center text-foreground-subtle",
24016
- children: "No state changes"
25241
+ !isLoading && capStates.size === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25242
+ className: "py-4 text-center text-foreground-subtle font-sans",
25243
+ children: "No runtime state"
24017
25244
  }),
24018
- allEntries.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24019
- className: "divide-y divide-border/30",
24020
- children: allEntries.map((entry) => {
24021
- const expanded = expandedRows.has(entry.id);
24022
- const fields = Object.entries(entry.slice);
24023
- const summary = fields.slice(0, 3).map(([k, v]) => `${k}=${formatValue(v)}`).join(" · ");
24024
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24025
- className: "flex items-start gap-2 px-3 py-1.5 hover:bg-surface-hover/30 cursor-pointer",
24026
- onClick: () => toggleRow(entry.id),
24027
- children: [
24028
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24029
- className: "text-foreground-subtle whitespace-nowrap w-[70px] shrink-0 pt-0.5",
24030
- children: new Date(entry.timestamp).toLocaleTimeString()
24031
- }),
24032
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCopyButton, {
24033
- copied: copiedRowId === entry.id,
24034
- onCopy: () => copyOne(entry)
24035
- }),
24036
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24037
- className: "flex-1 min-w-0",
24038
- children: [
24039
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24040
- className: "flex items-center gap-1.5",
24041
- children: [
24042
- expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 text-foreground-subtle shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3 text-foreground-subtle shrink-0" }),
24043
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24044
- className: "inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/15 text-violet-400 px-2 py-0.5 text-[9px] font-medium",
24045
- children: entry.capName
24046
- }),
24047
- fields.length > 3 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
24048
- className: "text-[9px] text-foreground-subtle",
24049
- children: [
24050
- "+",
24051
- fields.length - 3,
24052
- " more"
24053
- ]
24054
- })
24055
- ]
24056
- }),
24057
- summary && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
24058
- className: "text-foreground-subtle text-[10px] mt-0.5 ml-5 truncate",
24059
- children: summary
24060
- }),
24061
- expanded && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24062
- className: "mt-1 ml-5 text-[9px] bg-surface rounded px-2 py-1.5 space-y-0.5 font-mono overflow-x-auto",
24063
- children: fields.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24064
- className: "text-foreground-subtle italic",
24065
- children: "empty slice"
24066
- }) : fields.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
24067
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24068
- className: "text-primary/70",
24069
- children: k
24070
- }),
24071
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24072
- className: "text-foreground-subtle",
24073
- children: ": "
24074
- }),
24075
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24076
- className: "text-foreground break-all",
24077
- children: formatValue(v)
24078
- })
24079
- ] }, k))
24080
- })
24081
- ]
24082
- })
24083
- ]
24084
- }, entry.id);
25245
+ !isLoading && capStates.size > 0 && filteredCapNames.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25246
+ className: "py-4 text-center text-foreground-subtle font-sans",
25247
+ children: "No caps match the filter"
25248
+ }),
25249
+ filteredCapNames.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25250
+ className: "py-1",
25251
+ children: filteredCapNames.map((capName) => {
25252
+ const state = capStates.get(capName);
25253
+ if (!state) return null;
25254
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CapNode, {
25255
+ capName,
25256
+ slice: state.slice,
25257
+ updatedAt: state.updatedAt,
25258
+ searchNeedle,
25259
+ isPathOpen,
25260
+ onToggle: toggleNode
25261
+ }, capName);
24085
25262
  })
24086
25263
  })
24087
25264
  ]
24088
25265
  })]
24089
25266
  });
24090
25267
  }
25268
+ /** Top-level tree node: one capability. Header carries the cap-name
25269
+ * pill + an "updated Ns ago" indicator; the body expands to the
25270
+ * slice's keys. */
25271
+ function CapNode({ capName, slice, updatedAt, searchNeedle, isPathOpen, onToggle }) {
25272
+ const path = capName;
25273
+ const open = isPathOpen(path, 0);
25274
+ const entries = Object.entries(slice);
25275
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25276
+ className: "flex items-center gap-1.5 px-3 py-1 hover:bg-surface-hover/30 cursor-pointer select-none",
25277
+ onClick: () => onToggle(path, open),
25278
+ children: [
25279
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chevron, {
25280
+ open,
25281
+ hasChildren: entries.length > 0
25282
+ }),
25283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25284
+ className: "inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/15 text-violet-400 px-2 py-0.5 text-[9px] font-medium font-sans",
25285
+ children: capName
25286
+ }),
25287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
25288
+ className: "text-[9px] text-foreground-subtle font-sans",
25289
+ children: [
25290
+ entries.length,
25291
+ " ",
25292
+ entries.length === 1 ? "key" : "keys"
25293
+ ]
25294
+ }),
25295
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
25296
+ className: "ml-auto text-[9px] text-foreground-subtle/70 font-sans tabular-nums",
25297
+ children: ["updated ", formatAge(updatedAt)]
25298
+ })
25299
+ ]
25300
+ }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LeafEmpty, {
25301
+ depth: 1,
25302
+ label: "empty slice"
25303
+ }) : entries.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TreeNode, {
25304
+ path: `${path}.${k}`,
25305
+ nodeKey: k,
25306
+ value: v,
25307
+ depth: 1,
25308
+ searchNeedle,
25309
+ isPathOpen,
25310
+ onToggle
25311
+ }, k)) })] });
25312
+ }
25313
+ /** Recursive tree node. A leaf renders `key: value`; an object or
25314
+ * array renders a collapsible branch whose children recurse one
25315
+ * level deeper. */
25316
+ function TreeNode({ path, nodeKey, value, depth, searchNeedle, isPathOpen, onToggle }) {
25317
+ const branch = asBranch(value);
25318
+ const indentStyle = { paddingLeft: `${12 + depth * 14}px` };
25319
+ if (!branch) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25320
+ className: "flex items-start gap-1 px-3 py-0.5",
25321
+ style: indentStyle,
25322
+ children: [
25323
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25324
+ className: cn("shrink-0", highlightCls(nodeKey, searchNeedle, "text-primary/70")),
25325
+ children: nodeKey
25326
+ }),
25327
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25328
+ className: "text-foreground-subtle",
25329
+ children: ":"
25330
+ }),
25331
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25332
+ className: cn("break-all", highlightCls(formatValue(value), searchNeedle, valueCls(value))),
25333
+ children: formatValue(value)
25334
+ })
25335
+ ]
25336
+ });
25337
+ const open = isPathOpen(path, depth);
25338
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25339
+ className: "flex items-center gap-1 px-3 py-0.5 hover:bg-surface-hover/30 cursor-pointer select-none",
25340
+ style: indentStyle,
25341
+ onClick: () => onToggle(path, open),
25342
+ children: [
25343
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Chevron, {
25344
+ open,
25345
+ hasChildren: branch.entries.length > 0
25346
+ }),
25347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25348
+ className: highlightCls(nodeKey, searchNeedle, "text-primary/70"),
25349
+ children: nodeKey
25350
+ }),
25351
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25352
+ className: "text-foreground-subtle/70 text-[9px]",
25353
+ children: branch.summary
25354
+ })
25355
+ ]
25356
+ }), open && (branch.entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LeafEmpty, {
25357
+ depth: depth + 1,
25358
+ label: branch.kind === "array" ? "empty array" : "empty object"
25359
+ }) : branch.entries.map(([k, v]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TreeNode, {
25360
+ path: `${path}.${k}`,
25361
+ nodeKey: k,
25362
+ value: v,
25363
+ depth: depth + 1,
25364
+ searchNeedle,
25365
+ isPathOpen,
25366
+ onToggle
25367
+ }, k)))] });
25368
+ }
25369
+ /** Placeholder row for an object/array/slice with no entries. */
25370
+ function LeafEmpty({ depth, label }) {
25371
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25372
+ className: "px-3 py-0.5 text-foreground-subtle/60 italic",
25373
+ style: { paddingLeft: `${12 + depth * 14}px` },
25374
+ children: label
25375
+ });
25376
+ }
25377
+ /** Expand/collapse chevron. Renders a fixed-width spacer when the node
25378
+ * has no children so leaf and branch keys stay column-aligned. */
25379
+ function Chevron({ open, hasChildren }) {
25380
+ if (!hasChildren) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "inline-block w-3 shrink-0" });
25381
+ return open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 text-foreground-subtle shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3 text-foreground-subtle shrink-0" });
25382
+ }
25383
+ /** Classify a value as an expandable branch (plain object or array)
25384
+ * or `null` for a leaf. Arrays expose index → element entries. */
25385
+ function asBranch(value) {
25386
+ if (Array.isArray(value)) return {
25387
+ kind: "array",
25388
+ entries: value.map((v, i) => [String(i), v]),
25389
+ summary: `[${value.length}]`
25390
+ };
25391
+ if (value && typeof value === "object") {
25392
+ const entries = Object.entries(value);
25393
+ return {
25394
+ kind: "object",
25395
+ entries,
25396
+ summary: `{${entries.length}}`
25397
+ };
25398
+ }
25399
+ return null;
25400
+ }
24091
25401
  function formatValue(v) {
24092
25402
  if (v === null) return "null";
24093
25403
  if (v === void 0) return "undefined";
@@ -24099,9 +25409,46 @@ function formatValue(v) {
24099
25409
  return String(v);
24100
25410
  }
24101
25411
  }
25412
+ /** Tailwind colour for a leaf value by JS type. */
25413
+ function valueCls(v) {
25414
+ if (v === null || v === void 0) return "text-foreground-subtle/60";
25415
+ if (typeof v === "number") return "text-amber-400";
25416
+ if (typeof v === "boolean") return v ? "text-emerald-400" : "text-rose-400";
25417
+ return "text-foreground";
25418
+ }
25419
+ /** Append a highlight background when `text` matches the live search
25420
+ * needle, so search hits are visible inside the tree. */
25421
+ function highlightCls(text, needle, base) {
25422
+ if (needle && text.toLowerCase().includes(needle)) return cn(base, "bg-violet-500/20 rounded px-0.5");
25423
+ return base;
25424
+ }
25425
+ /** Deep search a slice — true when `needle` appears in any key or any
25426
+ * stringified leaf value anywhere in the tree. */
25427
+ function sliceMatchesSearch(value, needle) {
25428
+ if (Array.isArray(value)) return value.some((v) => sliceMatchesSearch(v, needle));
25429
+ if (value && typeof value === "object") {
25430
+ for (const [k, v] of Object.entries(value)) {
25431
+ if (k.toLowerCase().includes(needle)) return true;
25432
+ if (sliceMatchesSearch(v, needle)) return true;
25433
+ }
25434
+ return false;
25435
+ }
25436
+ return formatValue(value).toLowerCase().includes(needle);
25437
+ }
25438
+ /** Human "Ns ago" / "Nm ago" age label for the per-cap freshness
25439
+ * indicator. */
25440
+ function formatAge(updatedAt) {
25441
+ const deltaMs = Date.now() - updatedAt;
25442
+ if (deltaMs < 5e3) return "just now";
25443
+ const secs = Math.floor(deltaMs / 1e3);
25444
+ if (secs < 60) return `${secs}s ago`;
25445
+ const mins = Math.floor(secs / 60);
25446
+ if (mins < 60) return `${mins}m ago`;
25447
+ return `${Math.floor(mins / 60)}h ago`;
25448
+ }
24102
25449
  function ScopeBadge({ label, value }) {
24103
25450
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
24104
- className: "text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium",
25451
+ className: "text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium font-sans",
24105
25452
  children: [
24106
25453
  label,
24107
25454
  ": ",
@@ -24110,16 +25457,13 @@ function ScopeBadge({ label, value }) {
24110
25457
  });
24111
25458
  }
24112
25459
  /**
24113
- * Cap-name multiselect popover — same shape as
24114
- * `CategoryFilterPopover` in event-stream.tsx (search box at top,
24115
- * Select All / None / Defaults buttons, scrollable checkbox list).
24116
- * Caps don't have icons / styled badges like events do — we render
24117
- * a flat name + violet-pill bracket so the look stays consistent
24118
- * with the rest of the StateValuesStream rows.
25460
+ * Cap-name multiselect popover — same shape as `CategoryFilterPopover`
25461
+ * in event-stream.tsx (search box at top, Select All / None / Defaults
25462
+ * buttons, scrollable checkbox list).
24119
25463
  */
24120
25464
  function CapFilterPopover({ search, onSearchChange, caps, active, onToggle, onSelectAll, onSelectNone, onResetDefaults }) {
24121
25465
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24122
- className: "absolute z-20 top-full mt-1 left-0 w-72 max-h-80 rounded-md border border-border bg-surface shadow-lg flex flex-col",
25466
+ className: "absolute z-20 top-full mt-1 left-0 w-72 max-h-80 rounded-md border border-border bg-surface shadow-lg flex flex-col font-sans",
24123
25467
  onClick: (e) => e.stopPropagation(),
24124
25468
  children: [
24125
25469
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -24190,24 +25534,6 @@ function CapFilterPopover({ search, onSearchChange, caps, active, onToggle, onSe
24190
25534
  ]
24191
25535
  });
24192
25536
  }
24193
- /**
24194
- * Per-row Copy icon — same shape used in log-stream / event-stream
24195
- * so the three live-streams' rows are visually symmetric. Stops
24196
- * propagation so the click doesn't toggle the row's expand state.
24197
- * Flashes a Check for ~1s on success.
24198
- */
24199
- function RowCopyButton({ copied, onCopy }) {
24200
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24201
- type: "button",
24202
- title: "Copy this state change",
24203
- onClick: (e) => {
24204
- e.stopPropagation();
24205
- onCopy();
24206
- },
24207
- className: cn("rounded p-0.5 transition-colors mt-0.5", copied ? "text-emerald-400" : "text-foreground-subtle/60 hover:text-foreground hover:bg-surface-hover"),
24208
- children: copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3 w-3" })
24209
- });
24210
- }
24211
25537
  //#endregion
24212
25538
  //#region src/composites/device-activity-panel.tsx
24213
25539
  /**
@@ -24286,303 +25612,99 @@ function DeviceActivityPanel({ deviceId, defaultEventCategories, defaultStateCap
24286
25612
  showFilters: false,
24287
25613
  liveBuffer: logsBuffer
24288
25614
  }),
24289
- tab === "events" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventStream, {
24290
- deviceId,
24291
- defaultCategories: defaultEventCategories,
24292
- maxHeight,
24293
- showCategoryFilter: true,
24294
- liveBuffer: eventsBuffer
24295
- }),
24296
- tab === "state" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StateValuesStream, {
24297
- deviceId,
24298
- defaultCaps: defaultStateCaps,
24299
- maxHeight,
24300
- liveBuffer: stateBuffer
24301
- })
24302
- ]
24303
- })]
24304
- });
24305
- }
24306
- //#endregion
24307
- //#region src/composites/confirm-action-button.tsx
24308
- /**
24309
- * ConfirmActionButton — destructive-action button with a modal confirm.
24310
- *
24311
- * Two-step UX for any operation operators shouldn't trigger by accident:
24312
- * reboot device, restart addon, regenerate token, etc. The trigger is a
24313
- * normal Button that opens a Dialog; confirming runs the async action,
24314
- * the trigger spinner-disables itself for the duration, and the dialog
24315
- * closes on success. Errors surface inline at the dialog's bottom.
24316
- *
24317
- * Generic on `TResult` so callers don't have to discard the return
24318
- * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
24319
- * for reboot, RefreshCw for restart).
24320
- */
24321
- function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
24322
- const [open, setOpen] = (0, react$1.useState)(false);
24323
- const [running, setRunning] = (0, react$1.useState)(false);
24324
- const [error, setError] = (0, react$1.useState)(null);
24325
- const handleConfirm = async () => {
24326
- setError(null);
24327
- setRunning(true);
24328
- try {
24329
- const result = await action();
24330
- onSuccess?.(result);
24331
- setOpen(false);
24332
- } catch (err) {
24333
- setError(err instanceof Error ? err.message : String(err));
24334
- } finally {
24335
- setRunning(false);
24336
- }
24337
- };
24338
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
24339
- type: "button",
24340
- variant: triggerVariant,
24341
- size,
24342
- disabled,
24343
- onClick: () => {
24344
- setError(null);
24345
- setOpen(true);
24346
- },
24347
- className,
24348
- children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
24349
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
24350
- open,
24351
- onOpenChange: (next) => !running && setOpen(next),
24352
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
24353
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
24354
- className: "flex items-center gap-2",
24355
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
24356
- }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
24357
- error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24358
- className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
24359
- children: error
24360
- }),
24361
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
24362
- type: "button",
24363
- variant: "ghost",
24364
- size,
24365
- disabled: running,
24366
- onClick: () => setOpen(false),
24367
- children: "Cancel"
24368
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
24369
- type: "button",
24370
- variant: confirmVariant,
24371
- size,
24372
- disabled: running,
24373
- onClick: () => {
24374
- handleConfirm();
24375
- },
24376
- children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
24377
- })] })
24378
- ] })
24379
- })] });
24380
- }
24381
- //#endregion
24382
- //#region src/composites/ptz-overlay.tsx
24383
- /**
24384
- * PTZOverlay — pan / tilt / zoom controls.
24385
- *
24386
- * Two visual variants driven by `mode`:
24387
- * - `'overlay'` (default): translucent dark pill positioned bottom-
24388
- * right of the camera viewport. Used as `extraOverlay` on the
24389
- * `CameraStreamPlayer` so operators can drive PTZ without leaving
24390
- * the live view. Opaque background + subtle ring keeps the d-pad
24391
- * legible against any frame.
24392
- * - `'panel'`: full-bleed inside a host container (e.g. the floating
24393
- * PTZ panel in DeviceDetail). No absolute positioning, no inner
24394
- * wrapper card — the host's chrome is the only frame. Inherits the
24395
- * surrounding `bg-surface` so the dark theme reads consistently
24396
- * instead of the previous always-dark-bubble look.
24397
- *
24398
- * Interaction model is identical across modes: short tap fires a
24399
- * discrete pulse (`move`); long press starts continuous motion until
24400
- * release (`startContinuous` + `stopContinuous` on pointer up).
24401
- */
24402
- function DPadButton({ direction, icon: Icon, disabled, className, variant, onMove, onStart, onStop }) {
24403
- const [pressedAt, setPressedAt] = (0, react$1.useState)(null);
24404
- const [continuous, setContinuous] = (0, react$1.useState)(false);
24405
- const handlePointerDown = (0, react$1.useCallback)((e) => {
24406
- if (disabled) return;
24407
- e.currentTarget.setPointerCapture(e.pointerId);
24408
- setPressedAt(Date.now());
24409
- setContinuous(false);
24410
- const timer = setTimeout(() => {
24411
- setContinuous(true);
24412
- onStart(direction);
24413
- }, 250);
24414
- e.currentTarget.dataset.timer = String(timer);
24415
- }, [
24416
- direction,
24417
- disabled,
24418
- onStart
24419
- ]);
24420
- const handlePointerUp = (0, react$1.useCallback)((e) => {
24421
- const timerId = Number(e.currentTarget.dataset.timer);
24422
- if (timerId) clearTimeout(timerId);
24423
- e.currentTarget.dataset.timer = "";
24424
- if (continuous) onStop();
24425
- else if (pressedAt !== null) onMove(direction);
24426
- setPressedAt(null);
24427
- setContinuous(false);
24428
- }, [
24429
- continuous,
24430
- direction,
24431
- onMove,
24432
- onStop,
24433
- pressedAt
24434
- ]);
24435
- const handlePointerCancel = (0, react$1.useCallback)((e) => {
24436
- const timerId = Number(e.currentTarget.dataset.timer);
24437
- if (timerId) clearTimeout(timerId);
24438
- e.currentTarget.dataset.timer = "";
24439
- if (continuous) onStop();
24440
- setPressedAt(null);
24441
- setContinuous(false);
24442
- }, [continuous, onStop]);
24443
- const sizeClass = variant === "panel" ? "h-9 w-9" : "h-7 w-7";
24444
- const iconSizeClass = variant === "panel" ? "h-4 w-4" : "h-3.5 w-3.5";
24445
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24446
- type: "button",
24447
- disabled,
24448
- onPointerDown: handlePointerDown,
24449
- onPointerUp: handlePointerUp,
24450
- onPointerCancel: handlePointerCancel,
24451
- className: cn("flex items-center justify-center rounded transition-colors disabled:opacity-40", variant === "panel" ? "text-foreground hover:bg-surface-hover active:bg-surface-hover/80" : "text-white hover:bg-white/15 active:bg-white/25", sizeClass, className),
24452
- title: direction,
24453
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: iconSizeClass })
24454
- });
24455
- }
24456
- function PTZOverlay({ controls, mode = "overlay", showPresets, showZoom = true, showHome = true, className }) {
24457
- const { move, startContinuous, stopContinuous, zoom, goHome, goToPreset, presets, busy, error } = controls;
24458
- const [presetsOpen, setPresetsOpen] = (0, react$1.useState)(false);
24459
- const presetsVisible = (showPresets ?? presets.length > 0) && presets.length > 0;
24460
- const isPanel = mode === "panel";
24461
- const containerClass = isPanel ? "flex flex-col items-stretch gap-2 w-full h-full p-3" : "pointer-events-auto absolute top-3 right-3 z-10 flex flex-col items-end gap-2";
24462
- const rowClass = isPanel ? cn("flex items-center gap-3 rounded-lg p-2", busy && "ring-1 ring-primary/40") : cn("flex items-center gap-2 rounded-lg border border-white/30 bg-zinc-900/90 backdrop-blur-md p-2 shadow-lg shadow-black/40", busy && "ring-1 ring-primary/40");
24463
- const sideButtonSize = isPanel ? "h-9 w-9" : "h-7 w-7";
24464
- const sideIconSize = isPanel ? "h-4 w-4" : "h-3.5 w-3.5";
24465
- const sideButtonHover = isPanel ? "text-foreground hover:bg-surface-hover" : "text-white hover:bg-white/15";
24466
- const sepClass = isPanel ? "h-12 w-px bg-border mx-1" : "h-12 w-px bg-white/15 mx-1";
24467
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24468
- className: cn(containerClass, className),
24469
- children: [error && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24470
- className: "rounded bg-danger/90 px-2 py-1 text-[10px] font-medium text-white shadow-lg max-w-[200px] self-center",
24471
- children: ["PTZ: ", error]
24472
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24473
- className: cn(rowClass, isPanel && "justify-center"),
24474
- children: [
24475
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24476
- className: "grid grid-cols-3 gap-0.5",
24477
- children: [
24478
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24479
- "aria-hidden": true,
24480
- className: isPanel ? "h-9 w-9" : "h-7 w-7"
24481
- }),
24482
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
24483
- direction: "up",
24484
- icon: ArrowUp,
24485
- variant: mode,
24486
- onMove: move,
24487
- onStart: startContinuous,
24488
- onStop: stopContinuous
24489
- }),
24490
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24491
- "aria-hidden": true,
24492
- className: isPanel ? "h-9 w-9" : "h-7 w-7"
24493
- }),
24494
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
24495
- direction: "left",
24496
- icon: ArrowLeft,
24497
- variant: mode,
24498
- onMove: move,
24499
- onStart: startContinuous,
24500
- onStop: stopContinuous
24501
- }),
24502
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24503
- "aria-hidden": true,
24504
- className: isPanel ? "h-9 w-9" : "h-7 w-7"
24505
- }),
24506
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
24507
- direction: "right",
24508
- icon: ArrowRight,
24509
- variant: mode,
24510
- onMove: move,
24511
- onStart: startContinuous,
24512
- onStop: stopContinuous
24513
- }),
24514
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24515
- "aria-hidden": true,
24516
- className: isPanel ? "h-9 w-9" : "h-7 w-7"
24517
- }),
24518
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DPadButton, {
24519
- direction: "down",
24520
- icon: ArrowDown,
24521
- variant: mode,
24522
- onMove: move,
24523
- onStart: startContinuous,
24524
- onStop: stopContinuous
24525
- }),
24526
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
24527
- "aria-hidden": true,
24528
- className: isPanel ? "h-9 w-9" : "h-7 w-7"
24529
- })
24530
- ]
24531
- }),
24532
- (showZoom || showHome) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: sepClass }),
24533
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24534
- className: "flex flex-col gap-0.5",
24535
- children: [showZoom && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24536
- type: "button",
24537
- onClick: () => zoom("in"),
24538
- disabled: busy,
24539
- title: "Zoom in",
24540
- className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
24541
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { className: sideIconSize })
24542
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24543
- type: "button",
24544
- onClick: () => zoom("out"),
24545
- disabled: busy,
24546
- title: "Zoom out",
24547
- className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
24548
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { className: sideIconSize })
24549
- })] }), showHome && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24550
- type: "button",
24551
- onClick: () => goHome(),
24552
- disabled: busy,
24553
- title: "Go home",
24554
- className: cn("flex items-center justify-center rounded disabled:opacity-40", sideButtonSize, sideButtonHover),
24555
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(House, { className: sideIconSize })
24556
- })]
25615
+ tab === "events" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventStream, {
25616
+ deviceId,
25617
+ defaultCategories: defaultEventCategories,
25618
+ maxHeight,
25619
+ showCategoryFilter: true,
25620
+ liveBuffer: eventsBuffer
24557
25621
  }),
24558
- presetsVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
24559
- className: "relative",
24560
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
24561
- type: "button",
24562
- onClick: () => setPresetsOpen((v) => !v),
24563
- disabled: busy,
24564
- className: cn("flex items-center gap-1 rounded px-2 text-[10px] disabled:opacity-40", isPanel ? "h-9 text-foreground hover:bg-surface-hover" : "h-7 text-white hover:bg-white/15"),
24565
- title: "Presets",
24566
- children: ["Presets", /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: cn("h-3 w-3 transition-transform", presetsOpen && "rotate-180") })]
24567
- }), presetsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
24568
- className: cn("absolute right-0 min-w-[140px] rounded-lg shadow-xl py-1 z-20", isPanel ? "bottom-full mb-1 bg-surface border border-border" : "top-full mt-1 border border-white/30 bg-zinc-900/95 backdrop-blur-md"),
24569
- children: presets.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
24570
- type: "button",
24571
- onClick: () => {
24572
- goToPreset(p.id);
24573
- setPresetsOpen(false);
24574
- },
24575
- disabled: busy,
24576
- className: cn("block w-full px-3 py-1.5 text-left text-[10px] disabled:opacity-40", isPanel ? "text-foreground hover:bg-surface-hover" : "text-white hover:bg-white/15"),
24577
- children: p.name || p.id
24578
- }, p.id))
24579
- })]
25622
+ tab === "state" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StateValuesStream, {
25623
+ deviceId,
25624
+ defaultCaps: defaultStateCaps,
25625
+ maxHeight,
25626
+ liveBuffer: stateBuffer
24580
25627
  })
24581
25628
  ]
24582
25629
  })]
24583
25630
  });
24584
25631
  }
24585
25632
  //#endregion
25633
+ //#region src/composites/confirm-action-button.tsx
25634
+ /**
25635
+ * ConfirmActionButton — destructive-action button with a modal confirm.
25636
+ *
25637
+ * Two-step UX for any operation operators shouldn't trigger by accident:
25638
+ * reboot device, restart addon, regenerate token, etc. The trigger is a
25639
+ * normal Button that opens a Dialog; confirming runs the async action,
25640
+ * the trigger spinner-disables itself for the duration, and the dialog
25641
+ * closes on success. Errors surface inline at the dialog's bottom.
25642
+ *
25643
+ * Generic on `TResult` so callers don't have to discard the return
25644
+ * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
25645
+ * for reboot, RefreshCw for restart).
25646
+ */
25647
+ function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
25648
+ const [open, setOpen] = (0, react$1.useState)(false);
25649
+ const [running, setRunning] = (0, react$1.useState)(false);
25650
+ const [error, setError] = (0, react$1.useState)(null);
25651
+ const handleConfirm = async () => {
25652
+ setError(null);
25653
+ setRunning(true);
25654
+ try {
25655
+ const result = await action();
25656
+ onSuccess?.(result);
25657
+ setOpen(false);
25658
+ } catch (err) {
25659
+ setError(err instanceof Error ? err.message : String(err));
25660
+ } finally {
25661
+ setRunning(false);
25662
+ }
25663
+ };
25664
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
25665
+ type: "button",
25666
+ variant: triggerVariant,
25667
+ size,
25668
+ disabled,
25669
+ onClick: () => {
25670
+ setError(null);
25671
+ setOpen(true);
25672
+ },
25673
+ className,
25674
+ children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
25675
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
25676
+ open,
25677
+ onOpenChange: (next) => !running && setOpen(next),
25678
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
25679
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
25680
+ className: "flex items-center gap-2",
25681
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
25682
+ }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
25683
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25684
+ className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
25685
+ children: error
25686
+ }),
25687
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
25688
+ type: "button",
25689
+ variant: "ghost",
25690
+ size,
25691
+ disabled: running,
25692
+ onClick: () => setOpen(false),
25693
+ children: "Cancel"
25694
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
25695
+ type: "button",
25696
+ variant: confirmVariant,
25697
+ size,
25698
+ disabled: running,
25699
+ onClick: () => {
25700
+ handleConfirm();
25701
+ },
25702
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
25703
+ })] })
25704
+ ] })
25705
+ })] });
25706
+ }
25707
+ //#endregion
24586
25708
  //#region src/composites/snapshot-button.tsx
24587
25709
  /**
24588
25710
  * SnapshotButton — operator-facing manual snapshot trigger.
@@ -25489,169 +26611,6 @@ function useDeviceWebrtc(trpc, deviceId, pollIntervalMs = 5e3) {
25489
26611
  };
25490
26612
  }
25491
26613
  //#endregion
25492
- //#region src/hooks/use-ptz.ts
25493
- /**
25494
- * usePTZ — PTZ control hook for device-scoped pan / tilt / zoom.
25495
- *
25496
- * Wraps the `ptz` capability methods through the canonical
25497
- * `useDeviceProxy` surface — every call goes through
25498
- * `dev.ptz?.<method>(...)` with deviceId/nodeId auto-injected. The
25499
- * hook stays bare-bones around state (`busy`, `error`, `presets`)
25500
- * so the operator-facing component (PTZOverlay) stays trivial.
25501
- *
25502
- * Returns:
25503
- * - `move(direction)`: discrete one-shot move (cap.move + cap.stop).
25504
- * Use for short pulse moves triggered by tapping a d-pad button.
25505
- * - `startContinuous(direction)` / `stopContinuous()`: gesture-driven
25506
- * continuous motion (`cap.continuousMove` + `cap.stop`). Use for
25507
- * long-press handlers on touch / mouse-down handlers on desktop.
25508
- * - `zoom('in' | 'out')`: discrete zoom step.
25509
- * - `goHome()`: jump to preset 0.
25510
- * - `presets` + `goToPreset(presetId)`: list and jump to named presets.
25511
- *
25512
- * Parametrised by the same `UseDeviceProxyTrpc` shape every other
25513
- * device hook uses — works under admin-ui (BackendClient.trpc) and
25514
- * addon pages (AddonPageProps.trpc) alike.
25515
- */
25516
- var DIRECTION_VECTORS = {
25517
- "up": {
25518
- pan: 0,
25519
- tilt: 1
25520
- },
25521
- "down": {
25522
- pan: 0,
25523
- tilt: -1
25524
- },
25525
- "left": {
25526
- pan: -1,
25527
- tilt: 0
25528
- },
25529
- "right": {
25530
- pan: 1,
25531
- tilt: 0
25532
- },
25533
- "up-left": {
25534
- pan: -1,
25535
- tilt: 1
25536
- },
25537
- "up-right": {
25538
- pan: 1,
25539
- tilt: 1
25540
- },
25541
- "down-left": {
25542
- pan: -1,
25543
- tilt: -1
25544
- },
25545
- "down-right": {
25546
- pan: 1,
25547
- tilt: -1
25548
- }
25549
- };
25550
- function usePTZ(trpc, deviceId, options) {
25551
- const defaultSpeed = options?.defaultSpeed ?? .5;
25552
- const pulseMs = options?.pulseMs ?? 250;
25553
- const enabled = options?.enabled ?? true;
25554
- const ptz = useDeviceProxy(trpc, enabled ? deviceId : null)?.ptz;
25555
- const [presets, setPresets] = (0, react$1.useState)([]);
25556
- const [busy, setBusy] = (0, react$1.useState)(false);
25557
- const [error, setError] = (0, react$1.useState)(null);
25558
- const refreshPresets = (0, react$1.useCallback)(async () => {
25559
- if (!enabled || !ptz) return;
25560
- try {
25561
- setPresets(await ptz.getPresets({}));
25562
- } catch (err) {
25563
- const msg = err instanceof Error ? err.message : String(err);
25564
- if (msg.includes("provider not available") || msg.includes("no 'ptz' binding")) return;
25565
- setError(msg);
25566
- }
25567
- }, [ptz, enabled]);
25568
- (0, react$1.useEffect)(() => {
25569
- refreshPresets();
25570
- }, [refreshPresets]);
25571
- const wrap = (0, react$1.useCallback)(async (fn) => {
25572
- if (!enabled || !ptz) return void 0;
25573
- setError(null);
25574
- setBusy(true);
25575
- try {
25576
- return await fn();
25577
- } catch (err) {
25578
- setError(err instanceof Error ? err.message : String(err));
25579
- return;
25580
- } finally {
25581
- setBusy(false);
25582
- }
25583
- }, [enabled, ptz]);
25584
- return {
25585
- move: (0, react$1.useCallback)(async (direction, speed) => {
25586
- if (!ptz) return;
25587
- const v = DIRECTION_VECTORS[direction];
25588
- const s = speed ?? defaultSpeed;
25589
- await wrap(async () => {
25590
- await ptz.move({
25591
- pan: v.pan,
25592
- tilt: v.tilt,
25593
- speed: s
25594
- });
25595
- await new Promise((r) => setTimeout(r, pulseMs));
25596
- await ptz.stop({});
25597
- });
25598
- }, [
25599
- ptz,
25600
- defaultSpeed,
25601
- pulseMs,
25602
- wrap
25603
- ]),
25604
- startContinuous: (0, react$1.useCallback)(async (direction, speed) => {
25605
- if (!ptz) return;
25606
- const v = DIRECTION_VECTORS[direction];
25607
- const s = speed ?? defaultSpeed;
25608
- await wrap(() => ptz.continuousMove({
25609
- pan: v.pan,
25610
- tilt: v.tilt,
25611
- speed: s
25612
- }));
25613
- }, [
25614
- ptz,
25615
- defaultSpeed,
25616
- wrap
25617
- ]),
25618
- stopContinuous: (0, react$1.useCallback)(async () => {
25619
- if (!ptz) return;
25620
- await wrap(() => ptz.stop({}));
25621
- }, [ptz, wrap]),
25622
- zoom: (0, react$1.useCallback)(async (direction, speed) => {
25623
- if (!ptz) return;
25624
- const z = direction === "in" ? 1 : -1;
25625
- const s = speed ?? defaultSpeed;
25626
- await wrap(async () => {
25627
- await ptz.move({
25628
- zoom: z,
25629
- speed: s
25630
- });
25631
- await new Promise((r) => setTimeout(r, pulseMs));
25632
- await ptz.stop({});
25633
- });
25634
- }, [
25635
- ptz,
25636
- defaultSpeed,
25637
- pulseMs,
25638
- wrap
25639
- ]),
25640
- goHome: (0, react$1.useCallback)(async () => {
25641
- if (!ptz) return;
25642
- await wrap(() => ptz.goHome({}));
25643
- }, [ptz, wrap]),
25644
- goToPreset: (0, react$1.useCallback)(async (presetId) => {
25645
- if (!ptz) return;
25646
- await wrap(() => ptz.goToPreset({ presetId }));
25647
- }, [ptz, wrap]),
25648
- presets,
25649
- refreshPresets,
25650
- busy,
25651
- error
25652
- };
25653
- }
25654
- //#endregion
25655
26614
  //#region src/hooks/use-doorbell-events.ts
25656
26615
  /**
25657
26616
  * useDoorbellEvents — subscribe to doorbell.onPressed across the cluster.
@@ -25879,6 +26838,7 @@ exports.AppShell = AppShell;
25879
26838
  exports.AudioClassificationList = AudioClassificationList;
25880
26839
  exports.AudioLevelWaveform = AudioLevelWaveform;
25881
26840
  exports.AudioWaveform = AudioWaveform;
26841
+ exports.AutotrackSection = AutotrackSection;
25882
26842
  exports.BTN_COMPACT = BTN_COMPACT;
25883
26843
  exports.BTN_COMPACT_DANGER = BTN_COMPACT_DANGER;
25884
26844
  exports.BTN_COMPACT_PRIMARY = BTN_COMPACT_PRIMARY;
@@ -25941,6 +26901,7 @@ exports.FormField = FormField$1;
25941
26901
  exports.GRID_GAP = GRID_GAP;
25942
26902
  exports.GRID_PAIRED = GRID_PAIRED;
25943
26903
  exports.GRID_QUICK_STATS = GRID_QUICK_STATS;
26904
+ exports.HOST_WIDGETS = HOST_WIDGETS;
25944
26905
  exports.INPUT_COMPACT = INPUT_COMPACT;
25945
26906
  exports.IconButton = IconButton;
25946
26907
  exports.ImageSelector = ImageSelector;
@@ -25953,6 +26914,7 @@ exports.Label = Label;
25953
26914
  exports.LogStream = LogStream;
25954
26915
  exports.LoginForm = LoginForm;
25955
26916
  exports.MobileDrawer = MobileDrawer;
26917
+ exports.MotionZonesSettings = MotionZonesSettings;
25956
26918
  exports.NodeMultiSelectField = NodeMultiSelectField;
25957
26919
  exports.NodePicker = NodePicker;
25958
26920
  exports.NodeSelectField = NodeSelectField;
@@ -25969,6 +26931,7 @@ exports.Popover = Popover;
25969
26931
  exports.PopoverContent = PopoverContent;
25970
26932
  exports.PopoverTrigger = PopoverTrigger;
25971
26933
  exports.ProviderBadge = ProviderBadge;
26934
+ exports.PtzPanel = PtzPanel;
25972
26935
  exports.QrCode = QrCode;
25973
26936
  exports.ResponseLog = ResponseLog;
25974
26937
  exports.SECTION_BODY = SECTION_BODY;
@@ -26026,6 +26989,7 @@ exports.getClassColor = getClassColor;
26026
26989
  exports.getPhaseVisual = getPhaseVisual;
26027
26990
  exports.isFieldVisible = isFieldVisible;
26028
26991
  exports.lightColors = require_theme_index.lightColors$1;
26992
+ exports.loadRemoteBundle = loadRemoteBundle;
26029
26993
  exports.mirror = mirror;
26030
26994
  exports.mountAddonPage = mountAddonPage;
26031
26995
  exports.providerIcons = providerIcons;
@@ -26123,11 +27087,14 @@ exports.useCustomFieldRenderer = useCustomFieldRenderer;
26123
27087
  exports.useDebouncedString = useDebouncedString;
26124
27088
  exports.useDecoderCreateSession = useDecoderCreateSession;
26125
27089
  exports.useDecoderDestroySession = useDecoderDestroySession;
27090
+ exports.useDecoderGetFrame = useDecoderGetFrame;
26126
27091
  exports.useDecoderGetInfo = useDecoderGetInfo;
27092
+ exports.useDecoderGetShmStats = useDecoderGetShmStats;
26127
27093
  exports.useDecoderGetStats = useDecoderGetStats;
26128
27094
  exports.useDecoderListActiveSessions = useDecoderListActiveSessions;
26129
27095
  exports.useDecoderOpenStream = useDecoderOpenStream;
26130
27096
  exports.useDecoderPullFrames = useDecoderPullFrames;
27097
+ exports.useDecoderPullHandles = useDecoderPullHandles;
26131
27098
  exports.useDecoderPushPacket = useDecoderPushPacket;
26132
27099
  exports.useDecoderReprobeHwaccel = useDecoderReprobeHwaccel;
26133
27100
  exports.useDecoderSupportsCodec = useDecoderSupportsCodec;
@@ -26137,6 +27104,7 @@ exports.useDetectionPipelineGetDeviceLiveContribution = useDetectionPipelineGetD
26137
27104
  exports.useDetectionPipelineGetDeviceSettingsContribution = useDetectionPipelineGetDeviceSettingsContribution;
26138
27105
  exports.useDevShell = useDevShell;
26139
27106
  exports.useDevice = useDevice;
27107
+ exports.useDeviceAutotrack = useDeviceAutotrack;
26140
27108
  exports.useDeviceBattery = useDeviceBattery;
26141
27109
  exports.useDeviceCapability = useDeviceCapability;
26142
27110
  exports.useDeviceDetections = useDeviceDetections;
@@ -26291,6 +27259,9 @@ exports.useMotionGetStatus = useMotionGetStatus;
26291
27259
  exports.useMotionIsDetected = useMotionIsDetected;
26292
27260
  exports.useMotionTriggerGetStatus = useMotionTriggerGetStatus;
26293
27261
  exports.useMotionTriggerSetMotionTrigger = useMotionTriggerSetMotionTrigger;
27262
+ exports.useMotionZonesGetOptions = useMotionZonesGetOptions;
27263
+ exports.useMotionZonesGetStatus = useMotionZonesGetStatus;
27264
+ exports.useMotionZonesSetZone = useMotionZonesSetZone;
26294
27265
  exports.useMqttBrokerAddBroker = useMqttBrokerAddBroker;
26295
27266
  exports.useMqttBrokerGetBrokerConfig = useMqttBrokerGetBrokerConfig;
26296
27267
  exports.useMqttBrokerGetStatus = useMqttBrokerGetStatus;
@@ -26311,6 +27282,7 @@ exports.useNetworkQualityReportClientStats = useNetworkQualityReportClientStats;
26311
27282
  exports.useNodesClusterAddonStatus = useNodesClusterAddonStatus;
26312
27283
  exports.useNodesDeployAddon = useNodesDeployAddon;
26313
27284
  exports.useNodesExecuteQuery = useNodesExecuteQuery;
27285
+ exports.useNodesGetCapUsageGraph = useNodesGetCapUsageGraph;
26314
27286
  exports.useNodesGetNodeAddons = useNodesGetNodeAddons;
26315
27287
  exports.useNodesRenameNode = useNodesRenameNode;
26316
27288
  exports.useNodesRestartAddon = useNodesRestartAddon;
@@ -26433,12 +27405,16 @@ exports.usePtzAutotrackGetStatus = usePtzAutotrackGetStatus;
26433
27405
  exports.usePtzAutotrackSetEnabled = usePtzAutotrackSetEnabled;
26434
27406
  exports.usePtzAutotrackSetSettings = usePtzAutotrackSetSettings;
26435
27407
  exports.usePtzContinuousMove = usePtzContinuousMove;
27408
+ exports.usePtzDeletePreset = usePtzDeletePreset;
27409
+ exports.usePtzGetOptions = usePtzGetOptions;
26436
27410
  exports.usePtzGetPosition = usePtzGetPosition;
26437
27411
  exports.usePtzGetPresets = usePtzGetPresets;
26438
27412
  exports.usePtzGetStatus = usePtzGetStatus;
26439
27413
  exports.usePtzGoHome = usePtzGoHome;
26440
27414
  exports.usePtzGoToPreset = usePtzGoToPreset;
26441
27415
  exports.usePtzMove = usePtzMove;
27416
+ exports.usePtzSavePreset = usePtzSavePreset;
27417
+ exports.usePtzSetAutofocus = usePtzSetAutofocus;
26442
27418
  exports.usePtzStop = usePtzStop;
26443
27419
  exports.useRebootReboot = useRebootReboot;
26444
27420
  exports.useRecordingEngineDisable = useRecordingEngineDisable;
@@ -26462,6 +27438,7 @@ exports.useRecordingEngineUpdateRetentionConfig = useRecordingEngineUpdateRetent
26462
27438
  exports.useRecordingGetPlaybackUrl = useRecordingGetPlaybackUrl;
26463
27439
  exports.useRecordingGetSegments = useRecordingGetSegments;
26464
27440
  exports.useRecordingGetThumbnailAt = useRecordingGetThumbnailAt;
27441
+ exports.useRemoteComponent = useRemoteComponent;
26465
27442
  exports.useSettingsStoreCount = useSettingsStoreCount;
26466
27443
  exports.useSettingsStoreDeclareCollection = useSettingsStoreDeclareCollection;
26467
27444
  exports.useSettingsStoreDelete = useSettingsStoreDelete;
@@ -26503,7 +27480,6 @@ exports.useStorageWriteChunk = useStorageWriteChunk;
26503
27480
  exports.useStreamBrokerApplyDeviceSettingsPatch = useStreamBrokerApplyDeviceSettingsPatch;
26504
27481
  exports.useStreamBrokerAssignProfile = useStreamBrokerAssignProfile;
26505
27482
  exports.useStreamBrokerGetAllRtspEntries = useStreamBrokerGetAllRtspEntries;
26506
- exports.useStreamBrokerGetBroker = useStreamBrokerGetBroker;
26507
27483
  exports.useStreamBrokerGetBrokerStats = useStreamBrokerGetBrokerStats;
26508
27484
  exports.useStreamBrokerGetDeviceLiveContribution = useStreamBrokerGetDeviceLiveContribution;
26509
27485
  exports.useStreamBrokerGetDeviceSettingsContribution = useStreamBrokerGetDeviceSettingsContribution;
@@ -26518,13 +27494,23 @@ exports.useStreamBrokerListAllCameraStreams = useStreamBrokerListAllCameraStream
26518
27494
  exports.useStreamBrokerListAllProfileSlots = useStreamBrokerListAllProfileSlots;
26519
27495
  exports.useStreamBrokerListClients = useStreamBrokerListClients;
26520
27496
  exports.useStreamBrokerPublishCameraStream = useStreamBrokerPublishCameraStream;
27497
+ exports.useStreamBrokerPullAudioChunks = useStreamBrokerPullAudioChunks;
27498
+ exports.useStreamBrokerPullFrameHandles = useStreamBrokerPullFrameHandles;
26521
27499
  exports.useStreamBrokerRegenerateRtspToken = useStreamBrokerRegenerateRtspToken;
26522
27500
  exports.useStreamBrokerReleaseStreamWithCodec = useStreamBrokerReleaseStreamWithCodec;
26523
27501
  exports.useStreamBrokerRestartProfile = useStreamBrokerRestartProfile;
26524
27502
  exports.useStreamBrokerRetractCameraStream = useStreamBrokerRetractCameraStream;
26525
27503
  exports.useStreamBrokerSetPreBufferDuration = useStreamBrokerSetPreBufferDuration;
26526
27504
  exports.useStreamBrokerSetRtspEnabled = useStreamBrokerSetRtspEnabled;
27505
+ exports.useStreamBrokerSubscribeAudioChunks = useStreamBrokerSubscribeAudioChunks;
27506
+ exports.useStreamBrokerSubscribeFrames = useStreamBrokerSubscribeFrames;
26527
27507
  exports.useStreamBrokerUnassignProfile = useStreamBrokerUnassignProfile;
27508
+ exports.useStreamBrokerUnsubscribeAudioChunks = useStreamBrokerUnsubscribeAudioChunks;
27509
+ exports.useStreamBrokerUnsubscribeFrames = useStreamBrokerUnsubscribeFrames;
27510
+ exports.useStreamParamsGetConfigSchema = useStreamParamsGetConfigSchema;
27511
+ exports.useStreamParamsGetOptions = useStreamParamsGetOptions;
27512
+ exports.useStreamParamsGetStatus = useStreamParamsGetStatus;
27513
+ exports.useStreamParamsSetProfile = useStreamParamsSetProfile;
26528
27514
  exports.useSwitchGetStatus = useSwitchGetStatus;
26529
27515
  exports.useSwitchSetState = useSwitchSetState;
26530
27516
  exports.useSystem = useSystem;
@@ -26548,10 +27534,16 @@ exports.useUserManagementDeleteUser = useUserManagementDeleteUser;
26548
27534
  exports.useUserManagementDisableTotp = useUserManagementDisableTotp;
26549
27535
  exports.useUserManagementGetTotpStatus = useUserManagementGetTotpStatus;
26550
27536
  exports.useUserManagementListApiKeys = useUserManagementListApiKeys;
27537
+ exports.useUserManagementListOauthSessions = useUserManagementListOauthSessions;
26551
27538
  exports.useUserManagementListScopedTokens = useUserManagementListScopedTokens;
26552
27539
  exports.useUserManagementListUsers = useUserManagementListUsers;
27540
+ exports.useUserManagementOauthExchangeCode = useUserManagementOauthExchangeCode;
27541
+ exports.useUserManagementOauthIssueCode = useUserManagementOauthIssueCode;
27542
+ exports.useUserManagementOauthRefresh = useUserManagementOauthRefresh;
27543
+ exports.useUserManagementOauthVerifyAccessToken = useUserManagementOauthVerifyAccessToken;
26553
27544
  exports.useUserManagementResetPassword = useUserManagementResetPassword;
26554
27545
  exports.useUserManagementRevokeApiKey = useUserManagementRevokeApiKey;
27546
+ exports.useUserManagementRevokeOauthSession = useUserManagementRevokeOauthSession;
26555
27547
  exports.useUserManagementRevokeScopedToken = useUserManagementRevokeScopedToken;
26556
27548
  exports.useUserManagementSetUserScopes = useUserManagementSetUserScopes;
26557
27549
  exports.useUserManagementSetupTotp = useUserManagementSetupTotp;