@camstack/ui-library 1.1.15 → 1.1.17

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
@@ -7710,6 +7710,240 @@ function ensureMfHostInit() {
7710
7710
  });
7711
7711
  }
7712
7712
  //#endregion
7713
+ //#region src/lib/recorded-control-protocol.ts
7714
+ /** Serialize a command for `RTCDataChannel.send` (matches the server schema). */
7715
+ function serializeRecordedCommand(cmd) {
7716
+ return JSON.stringify(cmd);
7717
+ }
7718
+ /**
7719
+ * Playback mode reported by the server session. `live` = the shared live feed
7720
+ * owns the track; every other value describes the recorded feeder.
7721
+ */
7722
+ var RECORDED_PLAYBACK_MODES = [
7723
+ "live",
7724
+ "loading",
7725
+ "playing",
7726
+ "paused",
7727
+ "gap",
7728
+ "ended",
7729
+ "error"
7730
+ ];
7731
+ function isRecordedPlaybackMode(value) {
7732
+ return typeof value === "string" && RECORDED_PLAYBACK_MODES.includes(value);
7733
+ }
7734
+ /**
7735
+ * Parse a raw server→client control frame. Returns `null` on any malformed
7736
+ * payload (bad JSON, unknown discriminator, wrong field types).
7737
+ */
7738
+ function parseRecordedServerMessage(raw) {
7739
+ let parsed;
7740
+ try {
7741
+ parsed = JSON.parse(raw);
7742
+ } catch {
7743
+ return null;
7744
+ }
7745
+ if (typeof parsed !== "object" || parsed === null) return null;
7746
+ const obj = { ...parsed };
7747
+ if (obj.t === "state" && isRecordedPlaybackMode(obj.state)) return {
7748
+ kind: "state",
7749
+ state: obj.state
7750
+ };
7751
+ if (obj.t === "position" && typeof obj.epochMs === "number") return {
7752
+ kind: "position",
7753
+ epochMs: obj.epochMs
7754
+ };
7755
+ return null;
7756
+ }
7757
+ //#endregion
7758
+ //#region src/lib/scrub-controller.ts
7759
+ /**
7760
+ * scrub-controller — shared scrub-position state for timeline surfaces (R9).
7761
+ *
7762
+ * Ported from the proven srcV2 viewer implementation
7763
+ * (camstack/srcV2/recording/scrub-controller.ts): the same throttle,
7764
+ * commit-dedupe, and live→recorded-bypass semantics, now driving the
7765
+ * admin-ui `RecordingTimeline` through the `camstack-control` datachannel.
7766
+ *
7767
+ * Two layers:
7768
+ * 1. Pure reducer + helpers (unit-tested, no React deps):
7769
+ * scrubReducer, shouldEmit, shouldCommit, cursorFractionFor,
7770
+ * makeScrubBridge
7771
+ * 2. useScrubController hook: thin React glue that bridges the reducer
7772
+ * to a PlaybackControlSink (e.g. the recorded-playback datachannel).
7773
+ */
7774
+ var initialScrubState = {
7775
+ scrubbing: false,
7776
+ scrubEpoch: null,
7777
+ playheadEpoch: null,
7778
+ lastSource: null
7779
+ };
7780
+ function scrubReducer(state, action) {
7781
+ switch (action.type) {
7782
+ case "begin": return {
7783
+ ...state,
7784
+ scrubbing: true,
7785
+ scrubEpoch: null,
7786
+ lastSource: action.source
7787
+ };
7788
+ case "update": return {
7789
+ ...state,
7790
+ scrubbing: true,
7791
+ scrubEpoch: action.epoch,
7792
+ lastSource: action.source
7793
+ };
7794
+ case "end": return {
7795
+ ...state,
7796
+ scrubbing: false,
7797
+ scrubEpoch: null,
7798
+ playheadEpoch: action.epoch,
7799
+ lastSource: action.source
7800
+ };
7801
+ case "seek": return {
7802
+ ...state,
7803
+ scrubbing: false,
7804
+ scrubEpoch: null,
7805
+ playheadEpoch: action.epoch,
7806
+ lastSource: action.source
7807
+ };
7808
+ case "playhead":
7809
+ if (state.scrubbing) return state;
7810
+ return {
7811
+ ...state,
7812
+ playheadEpoch: action.epoch
7813
+ };
7814
+ case "reset": return initialScrubState;
7815
+ default: return state;
7816
+ }
7817
+ }
7818
+ /**
7819
+ * Throttle decision for continuous `update` emits (default ~70 ms).
7820
+ * Returns true when enough time has elapsed since the last emit.
7821
+ */
7822
+ function shouldEmit(lastEmitMs, now, throttleMs = 70) {
7823
+ return now - lastEmitMs >= throttleMs;
7824
+ }
7825
+ /** Epochs within this many ms of the last commit count as the SAME target. */
7826
+ var COMMIT_DEDUPE_TOLERANCE_MS = 750;
7827
+ /** Only dedupe against a commit made within this recent window. */
7828
+ var COMMIT_DEDUPE_WINDOW_MS = 2e3;
7829
+ /**
7830
+ * Decide whether a `seek`/`scrubCommit` should actually hit the playback sink.
7831
+ * Each commit reloads the server feeder (loading→playing) and visibly stutters
7832
+ * playback; firing the same (or a near-identical) target twice in quick
7833
+ * succession produces a redundant reload. Drop a commit only when it targets
7834
+ * essentially the SAME epoch as the last commit AND that commit was recent — a
7835
+ * genuinely different target, or a re-seek to the same spot after the window,
7836
+ * always commits.
7837
+ */
7838
+ function shouldCommit(last, epoch, now, toleranceMs = 750, windowMs = COMMIT_DEDUPE_WINDOW_MS) {
7839
+ if (last === null) return true;
7840
+ const sameTarget = Math.abs(epoch - last.epoch) <= toleranceMs;
7841
+ const recent = now - last.at <= windowMs;
7842
+ return !(sameTarget && recent);
7843
+ }
7844
+ /**
7845
+ * The active cursor position (scrub target while scrubbing, else playhead)
7846
+ * expressed as a 0..1 fraction across the day window.
7847
+ * Returns null when no position is known or the day window is degenerate.
7848
+ */
7849
+ function cursorFractionFor(state, day) {
7850
+ const epoch = state.scrubbing ? state.scrubEpoch : state.playheadEpoch;
7851
+ if (epoch === null) return null;
7852
+ const span = day.to - day.from;
7853
+ if (span <= 0) return null;
7854
+ return Math.min(1, Math.max(0, (epoch - day.from) / span));
7855
+ }
7856
+ /**
7857
+ * Builds the always-active bridge callbacks from a sink, a dispatch, and the
7858
+ * throttle ref. Pure (no React) so it can be unit-tested directly: the hook
7859
+ * just wraps the result in `useMemo`.
7860
+ *
7861
+ * Every gesture drives the sink immediately — there is no VOD-active gate.
7862
+ */
7863
+ function makeScrubBridge(sink, dispatch, lastEmitRef, lastCommitRef = { current: null }, isPlayerLive = () => false) {
7864
+ const commit = (epoch, fire) => {
7865
+ const now = Date.now();
7866
+ if (isPlayerLive()) {
7867
+ lastCommitRef.current = {
7868
+ epoch,
7869
+ at: now
7870
+ };
7871
+ fire(epoch);
7872
+ return;
7873
+ }
7874
+ if (!shouldCommit(lastCommitRef.current, epoch, now)) return;
7875
+ lastCommitRef.current = {
7876
+ epoch,
7877
+ at: now
7878
+ };
7879
+ fire(epoch);
7880
+ };
7881
+ return {
7882
+ beginScrub: (source) => {
7883
+ dispatch({
7884
+ type: "begin",
7885
+ source
7886
+ });
7887
+ sink.scrubStart();
7888
+ },
7889
+ updateScrub: (epoch, source) => {
7890
+ dispatch({
7891
+ type: "update",
7892
+ epoch,
7893
+ source
7894
+ });
7895
+ const now = Date.now();
7896
+ if (!shouldEmit(lastEmitRef.current, now)) return;
7897
+ lastEmitRef.current = now;
7898
+ sink.scrubTo(epoch);
7899
+ },
7900
+ endScrub: (epoch, source) => {
7901
+ dispatch({
7902
+ type: "end",
7903
+ epoch,
7904
+ source
7905
+ });
7906
+ commit(epoch, sink.scrubCommit);
7907
+ },
7908
+ seek: (epoch, source) => {
7909
+ dispatch({
7910
+ type: "seek",
7911
+ epoch,
7912
+ source
7913
+ });
7914
+ commit(epoch, sink.seek);
7915
+ },
7916
+ setPlayhead: (epoch) => dispatch({
7917
+ type: "playhead",
7918
+ epoch
7919
+ }),
7920
+ reset: () => dispatch({ type: "reset" })
7921
+ };
7922
+ }
7923
+ /**
7924
+ * Owns the shared scrub position and bridges it to a playback control sink.
7925
+ *
7926
+ * The bridge is always active: any gesture (drag or tap) drives the sink
7927
+ * immediately. There is no VOD-active gate — the timeline always drives
7928
+ * playback while frame-push mode is on.
7929
+ *
7930
+ * @param sink Always-active playback control surface (e.g. the datachannel)
7931
+ * @param isPlayerLive Synchronous getter for "is the player on the live
7932
+ * edge?" — lets a live→recorded commit bypass dedupe so the seek always
7933
+ * reaches the server.
7934
+ */
7935
+ function useScrubController(sink, isPlayerLive = () => false) {
7936
+ const [state, dispatch] = (0, react$1.useReducer)(scrubReducer, initialScrubState);
7937
+ const lastEmitRef = (0, react$1.useRef)(0);
7938
+ const lastCommitRef = (0, react$1.useRef)(null);
7939
+ const isPlayerLiveRef = (0, react$1.useRef)(isPlayerLive);
7940
+ isPlayerLiveRef.current = isPlayerLive;
7941
+ return {
7942
+ state,
7943
+ ...(0, react$1.useMemo)(() => makeScrubBridge(sink, dispatch, lastEmitRef, lastCommitRef, () => isPlayerLiveRef.current()), [sink])
7944
+ };
7945
+ }
7946
+ //#endregion
7713
7947
  //#region node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.js
7714
7948
  /**
7715
7949
  * @license lucide-react v0.576.0 - ISC
@@ -17765,10 +17999,22 @@ var useNodesGetNodeAddons = trpc.nodes.getNodeAddons.useQuery;
17765
17999
  var useNodesSetProcessLogLevel = trpc.nodes.setProcessLogLevel.useMutation;
17766
18000
  /** Generated alias around `trpc.nodes.executeQuery.useMutation`. */
17767
18001
  var useNodesExecuteQuery = trpc.nodes.executeQuery.useMutation;
18002
+ /** Generated alias around `trpc.notificationOutput.listTargetKinds.useQuery`. */
18003
+ var useNotificationOutputListTargetKinds = trpc.notificationOutput.listTargetKinds.useQuery;
18004
+ /** Generated alias around `trpc.notificationOutput.listTargets.useQuery`. */
18005
+ var useNotificationOutputListTargets = trpc.notificationOutput.listTargets.useQuery;
18006
+ /** Generated alias around `trpc.notificationOutput.discoverTargets.useQuery`. */
18007
+ var useNotificationOutputDiscoverTargets = trpc.notificationOutput.discoverTargets.useQuery;
17768
18008
  /** Generated alias around `trpc.notificationOutput.send.useMutation`. */
17769
18009
  var useNotificationOutputSend = trpc.notificationOutput.send.useMutation;
17770
- /** Generated alias around `trpc.notificationOutput.sendTest.useMutation`. */
17771
- var useNotificationOutputSendTest = trpc.notificationOutput.sendTest.useMutation;
18010
+ /** Generated alias around `trpc.notificationOutput.testTarget.useMutation`. */
18011
+ var useNotificationOutputTestTarget = trpc.notificationOutput.testTarget.useMutation;
18012
+ /** Generated alias around `trpc.notificationOutput.upsertTarget.useMutation`. */
18013
+ var useNotificationOutputUpsertTarget = trpc.notificationOutput.upsertTarget.useMutation;
18014
+ /** Generated alias around `trpc.notificationOutput.deleteTarget.useMutation`. */
18015
+ var useNotificationOutputDeleteTarget = trpc.notificationOutput.deleteTarget.useMutation;
18016
+ /** Generated alias around `trpc.notificationOutput.setTargetEnabled.useMutation`. */
18017
+ var useNotificationOutputSetTargetEnabled = trpc.notificationOutput.setTargetEnabled.useMutation;
17772
18018
  /** Generated alias around `trpc.notifier.send.useMutation`. */
17773
18019
  var useNotifierSend = trpc.notifier.send.useMutation;
17774
18020
  /** Generated alias around `trpc.notifier.cancel.useMutation`. */
@@ -32536,6 +32782,100 @@ function useVodPlayback() {
32536
32782
  return (0, react$1.useContext)(VodPlaybackContext);
32537
32783
  }
32538
32784
  //#endregion
32785
+ //#region src/contexts/recorded-playback.tsx
32786
+ /**
32787
+ * RecordedPlayback — shared context wiring the hero player's
32788
+ * `camstack-control` RTCDataChannel to the Recording timeline (R9).
32789
+ *
32790
+ * The hero `StreamPanel` (host side) BINDS the channel that
32791
+ * `CameraStreamPlayer` opens on the live WebRTC session; the Recording
32792
+ * widget's `RecordingTimeline` (controller side) SENDS frame-push playback
32793
+ * commands over it (seek / scrubCommit / setRate / goLive — the broker's
32794
+ * `TimelineSession` drives the `RecordedFeeder` on the SAME session, so
32795
+ * recorded frames arrive on the same `<video>` element) and OBSERVES the
32796
+ * server's playback state + ~1 Hz position reports.
32797
+ *
32798
+ * This is the admin-ui port of the shipped viewer's default recorded path
32799
+ * (srcV2 → embed → `camstack-control`), replacing "manifest-window HLS only".
32800
+ * The HLS VOD path (VodPlayback context) remains available as a fallback.
32801
+ *
32802
+ * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
32803
+ * the Module-Federation boundary — same mechanism as `vod-playback`.
32804
+ * Consumers outside a provider get the inert default: `bindControlChannel`
32805
+ * is null (StreamPanel then opens NO datachannel — plain live player) and
32806
+ * `channelOpen` is false (RecordingTimeline stays on the HLS path).
32807
+ */
32808
+ var RecordedPlaybackContext = createSharedContext("camstack:recorded-playback", {
32809
+ bindControlChannel: null,
32810
+ channelOpen: false,
32811
+ mode: "live",
32812
+ positionMs: null,
32813
+ send: () => false
32814
+ });
32815
+ function RecordedPlaybackProvider({ children }) {
32816
+ const channelRef = (0, react$1.useRef)(null);
32817
+ const [channelOpen, setChannelOpen] = (0, react$1.useState)(false);
32818
+ const [mode, setMode] = (0, react$1.useState)("live");
32819
+ const [positionMs, setPositionMs] = (0, react$1.useState)(null);
32820
+ const bindControlChannel = (0, react$1.useCallback)((channel) => {
32821
+ channelRef.current = channel;
32822
+ const markClosed = () => {
32823
+ if (channelRef.current !== channel) return;
32824
+ channelRef.current = null;
32825
+ setChannelOpen(false);
32826
+ setMode("live");
32827
+ setPositionMs(null);
32828
+ };
32829
+ channel.onopen = () => {
32830
+ if (channelRef.current === channel) setChannelOpen(true);
32831
+ };
32832
+ channel.onclose = markClosed;
32833
+ channel.onerror = markClosed;
32834
+ channel.onmessage = (e) => {
32835
+ if (channelRef.current !== channel) return;
32836
+ const data = e.data;
32837
+ if (typeof data !== "string") return;
32838
+ const msg = parseRecordedServerMessage(data);
32839
+ if (!msg) return;
32840
+ if (msg.kind === "position") setPositionMs(msg.epochMs);
32841
+ else {
32842
+ setMode(msg.state);
32843
+ if (msg.state === "live") setPositionMs(null);
32844
+ }
32845
+ };
32846
+ if (channel.readyState === "open") setChannelOpen(true);
32847
+ else if (channel.readyState === "closed" || channel.readyState === "closing") markClosed();
32848
+ else setChannelOpen(false);
32849
+ }, []);
32850
+ const send = (0, react$1.useCallback)((cmd) => {
32851
+ const channel = channelRef.current;
32852
+ if (!channel || channel.readyState !== "open") return false;
32853
+ channel.send(serializeRecordedCommand(cmd));
32854
+ return true;
32855
+ }, []);
32856
+ const value = (0, react$1.useMemo)(() => ({
32857
+ bindControlChannel,
32858
+ channelOpen,
32859
+ mode,
32860
+ positionMs,
32861
+ send
32862
+ }), [
32863
+ bindControlChannel,
32864
+ channelOpen,
32865
+ mode,
32866
+ positionMs,
32867
+ send
32868
+ ]);
32869
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RecordedPlaybackContext.Provider, {
32870
+ value,
32871
+ children
32872
+ });
32873
+ }
32874
+ /** @returns the recorded-playback control surface (inert outside a provider). */
32875
+ function useRecordedPlayback() {
32876
+ return (0, react$1.useContext)(RecordedPlaybackContext);
32877
+ }
32878
+ //#endregion
32539
32879
  //#region src/composites/cap-settings/recording-spans.ts
32540
32880
  /**
32541
32881
  * Merge availability ranges (across every profile) into the union of distinct
@@ -32781,8 +33121,12 @@ function axisTicks(fromMs, toMs, count) {
32781
33121
  }
32782
33122
  //#endregion
32783
33123
  //#region src/composites/cap-settings/CoverageTrack.tsx
32784
- function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
33124
+ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScrubStart, onScrubMove, onScrubEnd }) {
32785
33125
  const [hoverInfo, setHoverInfo] = (0, react$1.useState)(null);
33126
+ const scrubEnabled = Boolean(onScrubEnd);
33127
+ const [dragging, setDragging] = (0, react$1.useState)(false);
33128
+ /** Last epoch reported during the drag — commit target on cancel/release. */
33129
+ const lastDragMsRef = (0, react$1.useRef)(null);
32786
33130
  const msFromEvent = (e) => {
32787
33131
  const rect = e.currentTarget.getBoundingClientRect();
32788
33132
  if (rect.width <= 0) return null;
@@ -32790,10 +33134,43 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
32790
33134
  return windowFrom + Math.min(1, Math.max(0, ratio)) * (windowTo - windowFrom);
32791
33135
  };
32792
33136
  const handleClick = (e) => {
33137
+ if (scrubEnabled) return;
32793
33138
  const ms = msFromEvent(e);
32794
33139
  if (ms === null) return;
32795
33140
  onSeek(ms);
32796
33141
  };
33142
+ const handlePointerDown = (e) => {
33143
+ if (!scrubEnabled) return;
33144
+ e.currentTarget.setPointerCapture(e.pointerId);
33145
+ setDragging(true);
33146
+ onScrubStart?.();
33147
+ const ms = msFromEvent(e);
33148
+ if (ms !== null) {
33149
+ lastDragMsRef.current = ms;
33150
+ onScrubMove?.(ms);
33151
+ }
33152
+ };
33153
+ const handlePointerMove = (e) => {
33154
+ if (!scrubEnabled || !dragging) return;
33155
+ const ms = msFromEvent(e);
33156
+ if (ms === null) return;
33157
+ lastDragMsRef.current = ms;
33158
+ onScrubMove?.(ms);
33159
+ };
33160
+ const handlePointerUp = (e) => {
33161
+ if (!scrubEnabled || !dragging) return;
33162
+ setDragging(false);
33163
+ const ms = msFromEvent(e) ?? lastDragMsRef.current;
33164
+ lastDragMsRef.current = null;
33165
+ if (ms !== null) onScrubEnd?.(ms);
33166
+ };
33167
+ const handlePointerCancel = () => {
33168
+ if (!dragging) return;
33169
+ setDragging(false);
33170
+ const ms = lastDragMsRef.current;
33171
+ lastDragMsRef.current = null;
33172
+ if (ms !== null) onScrubEnd?.(ms);
33173
+ };
32797
33174
  const handleMouseMove = (e) => {
32798
33175
  const ms = msFromEvent(e);
32799
33176
  if (ms === null) return;
@@ -32817,10 +33194,15 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
32817
33194
  className: "relative",
32818
33195
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32819
33196
  "data-testid": "coverage-track",
33197
+ "data-scrubbing": dragging ? "true" : "false",
32820
33198
  onClick: handleClick,
33199
+ onPointerDown: handlePointerDown,
33200
+ onPointerMove: handlePointerMove,
33201
+ onPointerUp: handlePointerUp,
33202
+ onPointerCancel: handlePointerCancel,
32821
33203
  onMouseMove: handleMouseMove,
32822
33204
  onMouseLeave: handleMouseLeave,
32823
- className: "relative h-5 cursor-pointer overflow-hidden rounded bg-background/40",
33205
+ className: `relative h-5 overflow-hidden rounded bg-background/40 ${scrubEnabled ? "cursor-ew-resize touch-none select-none" : "cursor-pointer"}`,
32824
33206
  role: "group",
32825
33207
  "aria-label": "Recorded coverage timeline",
32826
33208
  children: [spans.map((s) => {
@@ -33119,6 +33501,15 @@ var PAN_MS_PER_PX = 18e3 / 100;
33119
33501
  var EVENT_PRE_MS = 5e3;
33120
33502
  var EVENT_POST_MS = 1e4;
33121
33503
  var EMPTY_RANGES = [];
33504
+ var MODE_LABELS = {
33505
+ live: "Live",
33506
+ loading: "Loading…",
33507
+ playing: "Playing",
33508
+ paused: "Paused",
33509
+ gap: "No footage",
33510
+ ended: "Ended",
33511
+ error: "Error"
33512
+ };
33122
33513
  function RecordingTimeline({ deviceId }) {
33123
33514
  const system = useSystem();
33124
33515
  const vod = useVodPlayback();
@@ -33126,6 +33517,51 @@ function RecordingTimeline({ deviceId }) {
33126
33517
  const [playheadMs, setPlayheadMs] = (0, react$1.useState)(null);
33127
33518
  const [busyKey, setBusyKey] = (0, react$1.useState)(null);
33128
33519
  const [playbackError, setPlaybackError] = (0, react$1.useState)(null);
33520
+ const recorded = useRecordedPlayback();
33521
+ const { send: sendControl, mode: recordedMode, positionMs: recordedPositionMs } = recorded;
33522
+ const [forceHls, setForceHls] = (0, react$1.useState)(false);
33523
+ const framePush = recorded.channelOpen && !forceHls;
33524
+ const framePushRef = (0, react$1.useRef)(framePush);
33525
+ framePushRef.current = framePush;
33526
+ const sink = (0, react$1.useMemo)(() => ({
33527
+ scrubStart: () => {},
33528
+ scrubTo: () => {},
33529
+ scrubCommit: (epochMs) => {
33530
+ sendControl({
33531
+ t: "scrubCommit",
33532
+ epoch: epochMs
33533
+ });
33534
+ },
33535
+ seek: (epochMs) => {
33536
+ sendControl({
33537
+ t: "seek",
33538
+ epoch: epochMs
33539
+ });
33540
+ }
33541
+ }), [sendControl]);
33542
+ const recordedModeRef = (0, react$1.useRef)(recordedMode);
33543
+ recordedModeRef.current = recordedMode;
33544
+ const { state: scrubState, beginScrub, updateScrub, endScrub, seek: scrubSeek, setPlayhead, reset: resetScrub } = useScrubController(sink, (0, react$1.useCallback)(() => recordedModeRef.current === "live", []));
33545
+ (0, react$1.useEffect)(() => {
33546
+ if (recordedPositionMs !== null) setPlayhead(recordedPositionMs);
33547
+ }, [recordedPositionMs, setPlayhead]);
33548
+ (0, react$1.useEffect)(() => {
33549
+ if (!recorded.channelOpen || recordedMode === "live") resetScrub();
33550
+ }, [
33551
+ recorded.channelOpen,
33552
+ recordedMode,
33553
+ resetScrub
33554
+ ]);
33555
+ const handleGoLive = (0, react$1.useCallback)(() => {
33556
+ sendControl({ t: "goLive" });
33557
+ resetScrub();
33558
+ }, [sendControl, resetScrub]);
33559
+ const handleRateToggle = (0, react$1.useCallback)(() => {
33560
+ sendControl({
33561
+ t: "setRate",
33562
+ rate: recordedModeRef.current === "paused" ? 1 : 0
33563
+ });
33564
+ }, [sendControl]);
33129
33565
  const [enabledKinds, setEnabledKinds] = (0, react$1.useState)(() => new Set(ALL_KINDS));
33130
33566
  const toggleKind = (0, react$1.useCallback)((kind) => {
33131
33567
  setEnabledKinds((current) => {
@@ -33279,6 +33715,10 @@ function RecordingTimeline({ deviceId }) {
33279
33715
  const handleSeek = (0, react$1.useCallback)((clickMs) => {
33280
33716
  const target = seekTargetForSpans(clickMs, spans);
33281
33717
  if (!target) return;
33718
+ if (framePushRef.current) {
33719
+ scrubSeek(target.fromMs, "timeline");
33720
+ return;
33721
+ }
33282
33722
  const label = `Recording · ${new Date(target.fromMs).toLocaleString(void 0, {
33283
33723
  month: "short",
33284
33724
  day: "numeric",
@@ -33286,14 +33726,31 @@ function RecordingTimeline({ deviceId }) {
33286
33726
  minute: "2-digit"
33287
33727
  })}`;
33288
33728
  playWindow(`seek:${target.fromMs}`, target.fromMs, target.toMs, label, target.fromMs);
33289
- }, [spans, playWindow]);
33729
+ }, [
33730
+ spans,
33731
+ playWindow,
33732
+ scrubSeek
33733
+ ]);
33734
+ const handleScrubStart = (0, react$1.useCallback)(() => {
33735
+ beginScrub("timeline");
33736
+ }, [beginScrub]);
33737
+ const handleScrubMove = (0, react$1.useCallback)((ms) => {
33738
+ updateScrub(ms, "timeline");
33739
+ }, [updateScrub]);
33740
+ const handleScrubEnd = (0, react$1.useCallback)((ms) => {
33741
+ endScrub(seekTargetForSpans(ms, spans)?.fromMs ?? ms, "timeline");
33742
+ }, [spans, endScrub]);
33290
33743
  const handlePlayClip = (0, react$1.useCallback)((clip) => {
33744
+ if (framePushRef.current) {
33745
+ scrubSeek(clip.timestamp - EVENT_PRE_MS, "timeline");
33746
+ return;
33747
+ }
33291
33748
  const label = `${clip.label} · ${new Date(clip.timestamp).toLocaleTimeString(void 0, {
33292
33749
  hour: "2-digit",
33293
33750
  minute: "2-digit"
33294
33751
  })}`;
33295
33752
  playWindow(`event:${clip.id}`, clip.timestamp - EVENT_PRE_MS, clip.timestamp + EVENT_POST_MS, label, clip.timestamp);
33296
- }, [playWindow]);
33753
+ }, [playWindow, scrubSeek]);
33297
33754
  const handleSelectBucket = (0, react$1.useCallback)((index) => {
33298
33755
  setSelectedBucket((cur) => cur === index ? null : index);
33299
33756
  }, []);
@@ -33301,6 +33758,8 @@ function RecordingTimeline({ deviceId }) {
33301
33758
  const densityLoading = eventDensityQuery.isLoading;
33302
33759
  const drillLoading = (objectDrill.isLoading || motionDrill.isLoading || audioDrill.isLoading) && drillEnabled;
33303
33760
  const ticks = (0, react$1.useMemo)(() => axisTicks(view.fromMs, view.toMs, 5), [view.fromMs, view.toMs]);
33761
+ const displayPlayheadMs = framePush ? scrubState.scrubbing ? scrubState.scrubEpoch : scrubState.playheadEpoch : playheadMs;
33762
+ const showRateToggle = framePush && (recordedMode === "playing" || recordedMode === "paused");
33304
33763
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33305
33764
  className: "space-y-3",
33306
33765
  children: [
@@ -33319,6 +33778,45 @@ function RecordingTimeline({ deviceId }) {
33319
33778
  dayBounds: computedDayBounds,
33320
33779
  onViewChange: handleViewChange
33321
33780
  }),
33781
+ recorded.channelOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33782
+ "data-testid": "frame-push-status-row",
33783
+ className: "flex flex-wrap items-center gap-2 text-[10px]",
33784
+ children: [
33785
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33786
+ "data-testid": "frame-push-mode-chip",
33787
+ className: `inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-semibold uppercase tracking-wider ${framePush && recordedMode !== "live" ? recordedMode === "error" ? "bg-rose-500/15 text-rose-400" : "bg-primary/15 text-primary" : "bg-background/40 text-foreground-subtle"}`,
33788
+ children: framePush ? MODE_LABELS[recordedMode] : "HLS mode"
33789
+ }),
33790
+ framePush && recordedPositionMs !== null && !scrubState.scrubbing ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
33791
+ className: "tabular-nums text-foreground-subtle",
33792
+ children: new Date(recordedPositionMs).toLocaleTimeString()
33793
+ }) : null,
33794
+ showRateToggle ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
33795
+ type: "button",
33796
+ "data-testid": "frame-push-rate-toggle",
33797
+ onClick: handleRateToggle,
33798
+ className: "inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium text-foreground-subtle transition-colors hover:bg-surface-hover hover:text-foreground",
33799
+ title: recordedMode === "paused" ? "Resume playback" : "Pause playback",
33800
+ children: [recordedMode === "paused" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-3 w-3" }), recordedMode === "paused" ? "Resume" : "Pause"]
33801
+ }) : null,
33802
+ framePush && recordedMode !== "live" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
33803
+ type: "button",
33804
+ "data-testid": "frame-push-go-live",
33805
+ onClick: handleGoLive,
33806
+ className: "inline-flex items-center gap-1 rounded bg-danger/10 px-1.5 py-0.5 font-medium text-danger transition-colors hover:bg-danger/20",
33807
+ title: "Return to the live stream",
33808
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Radio, { className: "h-3 w-3" }), "Go live"]
33809
+ }) : null,
33810
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
33811
+ type: "button",
33812
+ "data-testid": "frame-push-hls-toggle",
33813
+ onClick: () => setForceHls((v) => !v),
33814
+ className: `ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium transition-colors ${forceHls ? "bg-primary/15 text-primary" : "text-foreground-subtle hover:bg-surface-hover hover:text-foreground"}`,
33815
+ title: "Play recordings through the HLS player instead of the live WebRTC session",
33816
+ children: "HLS"
33817
+ })
33818
+ ]
33819
+ }) : null,
33322
33820
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33323
33821
  ref: trackAreaRef,
33324
33822
  onWheel: handleWheel,
@@ -33336,8 +33834,13 @@ function RecordingTimeline({ deviceId }) {
33336
33834
  windowFrom: view.fromMs,
33337
33835
  windowTo: view.toMs,
33338
33836
  spans,
33339
- playheadMs,
33340
- onSeek: handleSeek
33837
+ playheadMs: displayPlayheadMs,
33838
+ onSeek: handleSeek,
33839
+ ...framePush ? {
33840
+ onScrubStart: handleScrubStart,
33841
+ onScrubMove: handleScrubMove,
33842
+ onScrubEnd: handleScrubEnd
33843
+ } : {}
33341
33844
  })]
33342
33845
  }),
33343
33846
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -33374,7 +33877,7 @@ function RecordingTimeline({ deviceId }) {
33374
33877
  onLoadMore: handleLoadMore
33375
33878
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
33376
33879
  className: "text-[11px] text-foreground-subtle",
33377
- children: "Click the coverage bar to play from any point, or a density bar to browse that window's events."
33880
+ children: framePush ? "Click or drag the coverage bar to play from any point (frames play in the main player above), or click a density bar to browse that windows events." : "Click the coverage bar to play from any point, or a density bar to browse that window’s events."
33378
33881
  })
33379
33882
  ]
33380
33883
  });
@@ -38662,6 +39165,7 @@ function ImageOffIcon({ className }) {
38662
39165
  }
38663
39166
  function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, playbackUrl, playbackAuthToken, onExitPlayback, posterUrl, streams, activeStreamId: controlledStreamId, onStreamChange, deviceName, phase, pipelineMetrics, detections, defaultShowDetections = true, defaultShowMotion = false, streamStats, autoPlay = false, showPlayStop = false, snapshotSrc, snapshotLoading = false, onRefreshSnapshot, showStreamStats = false, children, extraOverlay, ptzAvailable = false, ptzShown = false, ptzOverlay, onPtzToggle, intercomAvailable = false, intercomShown = false, onIntercomToggle, chromeless = false, className }) {
38664
39167
  const registeredButtons = usePlayerToolbarButtons();
39168
+ const recordedPlayback = useRecordedPlayback();
38665
39169
  const [isPlaying, setIsPlaying] = (0, react$1.useState)(autoPlay);
38666
39170
  const [showDetections, setShowDetections] = (0, react$1.useState)(defaultShowDetections);
38667
39171
  const [showMotion, setShowMotion] = (0, react$1.useState)(defaultShowMotion);
@@ -38967,6 +39471,7 @@ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIce
38967
39471
  addIceCandidate,
38968
39472
  getIceCandidates,
38969
39473
  closeSession,
39474
+ ...recordedPlayback.bindControlChannel ? { onControlChannel: recordedPlayback.bindControlChannel } : {},
38970
39475
  ...posterUrl ? { posterUrl } : {},
38971
39476
  overlay: anyOverlay || extraOverlay || ptzOverlayVisible ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
38972
39477
  anyOverlay && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DetectionOverlay, {
@@ -43610,6 +44115,8 @@ exports.CHIP_INACTIVE = CHIP_INACTIVE;
43610
44115
  exports.CLASS_COLORS = CLASS_COLORS;
43611
44116
  exports.COLUMN_BREAKPOINT_CLASS = COLUMN_BREAKPOINT_CLASS;
43612
44117
  exports.COLUMN_PRIORITY = COLUMN_PRIORITY;
44118
+ exports.COMMIT_DEDUPE_TOLERANCE_MS = COMMIT_DEDUPE_TOLERANCE_MS;
44119
+ exports.COMMIT_DEDUPE_WINDOW_MS = COMMIT_DEDUPE_WINDOW_MS;
43613
44120
  exports.CONTROL_CAP_NAMES = CONTROL_CAP_NAMES;
43614
44121
  exports.CONTROL_FILLS = CONTROL_FILLS;
43615
44122
  exports.CameraStreamPlayer = CameraStreamPlayer;
@@ -43744,9 +44251,11 @@ exports.PrivacyMaskSettings = PrivacyMaskSettings;
43744
44251
  exports.ProviderBadge = ProviderBadge;
43745
44252
  exports.PtzPanel = PtzPanel;
43746
44253
  exports.QrCode = QrCode;
44254
+ exports.RECORDED_PLAYBACK_MODES = RECORDED_PLAYBACK_MODES;
43747
44255
  exports.RIGHT = RIGHT;
43748
44256
  exports.ROLE_DESCRIPTOR = ROLE_DESCRIPTOR;
43749
44257
  exports.RadialGauge = RadialGauge;
44258
+ exports.RecordedPlaybackProvider = RecordedPlaybackProvider;
43750
44259
  exports.RecordingPanel = RecordingPanel;
43751
44260
  exports.ResponseLog = ResponseLog;
43752
44261
  exports.SECTION_BODY = SECTION_BODY;
@@ -43830,6 +44339,7 @@ exports.coverHighlight = coverHighlight;
43830
44339
  exports.createLucideIcon = createLucideIcon;
43831
44340
  exports.createSharedContext = createSharedContext;
43832
44341
  exports.createTheme = require_theme_index.createTheme$1;
44342
+ exports.cursorFractionFor = cursorFractionFor;
43833
44343
  exports.darkColors = require_theme_index.darkColors$1;
43834
44344
  exports.defaultTheme = require_theme_index.defaultTheme$1;
43835
44345
  exports.deriveDeviceKind = deriveDeviceKind;
@@ -43847,12 +44357,14 @@ exports.getPhaseVisual = getPhaseVisual;
43847
44357
  exports.groupChildrenByLayout = groupChildrenByLayout;
43848
44358
  exports.hardwareLabel = hardwareLabel;
43849
44359
  exports.humidifierTint = humidifierTint;
44360
+ exports.initialScrubState = initialScrubState;
43850
44361
  exports.isAbsentProvider = isAbsentProvider;
43851
44362
  exports.isAbsentProvider$1 = isAbsentProvider;
43852
44363
  exports.isFieldVisible = isFieldVisible;
43853
44364
  exports.lawnMowerActivityMeta = lawnMowerActivityMeta;
43854
44365
  exports.lightColors = require_theme_index.lightColors$1;
43855
44366
  exports.loadRemoteBundle = loadRemoteBundle;
44367
+ exports.makeScrubBridge = makeScrubBridge;
43856
44368
  exports.metadataEntries = metadataEntries;
43857
44369
  exports.metadataString = metadataString;
43858
44370
  exports.mirror = mirror;
@@ -43860,11 +44372,16 @@ exports.mountAddonPage = mountAddonPage;
43860
44372
  exports.nextSort = nextSort;
43861
44373
  exports.normalizeForSearch = normalizeForSearch;
43862
44374
  exports.overrideEntityIdFromLink = overrideEntityIdFromLink;
44375
+ exports.parseRecordedServerMessage = parseRecordedServerMessage;
43863
44376
  exports.providerIcons = providerIcons;
43864
44377
  exports.resolveContainerPrimary = resolveContainerPrimary;
43865
44378
  exports.resolveControlAlign = resolveControlAlign;
43866
44379
  exports.resolveDeviceControl = resolveDeviceControl;
43867
44380
  exports.resolvePrimaryChild = resolvePrimaryChild;
44381
+ exports.scrubReducer = scrubReducer;
44382
+ exports.serializeRecordedCommand = serializeRecordedCommand;
44383
+ exports.shouldCommit = shouldCommit;
44384
+ exports.shouldEmit = shouldEmit;
43868
44385
  exports.sortRows = sortRows;
43869
44386
  exports.statusIcons = statusIcons;
43870
44387
  exports.tankAlert = tankAlert;
@@ -44320,8 +44837,14 @@ exports.useNodesSetProcessLogLevel = useNodesSetProcessLogLevel;
44320
44837
  exports.useNodesShutdownNode = useNodesShutdownNode;
44321
44838
  exports.useNodesTopology = useNodesTopology;
44322
44839
  exports.useNodesUndeployAddon = useNodesUndeployAddon;
44840
+ exports.useNotificationOutputDeleteTarget = useNotificationOutputDeleteTarget;
44841
+ exports.useNotificationOutputDiscoverTargets = useNotificationOutputDiscoverTargets;
44842
+ exports.useNotificationOutputListTargetKinds = useNotificationOutputListTargetKinds;
44843
+ exports.useNotificationOutputListTargets = useNotificationOutputListTargets;
44323
44844
  exports.useNotificationOutputSend = useNotificationOutputSend;
44324
- exports.useNotificationOutputSendTest = useNotificationOutputSendTest;
44845
+ exports.useNotificationOutputSetTargetEnabled = useNotificationOutputSetTargetEnabled;
44846
+ exports.useNotificationOutputTestTarget = useNotificationOutputTestTarget;
44847
+ exports.useNotificationOutputUpsertTarget = useNotificationOutputUpsertTarget;
44325
44848
  exports.useNotifierCancel = useNotifierCancel;
44326
44849
  exports.useNotifierGetStatus = useNotifierGetStatus;
44327
44850
  exports.useNotifierSend = useNotifierSend;
@@ -44464,6 +44987,7 @@ exports.usePtzSavePreset = usePtzSavePreset;
44464
44987
  exports.usePtzSetAutofocus = usePtzSetAutofocus;
44465
44988
  exports.usePtzStop = usePtzStop;
44466
44989
  exports.useRebootReboot = useRebootReboot;
44990
+ exports.useRecordedPlayback = useRecordedPlayback;
44467
44991
  exports.useRecordingApplyDeviceSettingsPatch = useRecordingApplyDeviceSettingsPatch;
44468
44992
  exports.useRecordingGetAvailability = useRecordingGetAvailability;
44469
44993
  exports.useRecordingGetDaysWithRecordings = useRecordingGetDaysWithRecordings;
@@ -44482,6 +45006,7 @@ exports.useRemoteComponent = useRemoteComponent;
44482
45006
  exports.useScriptRunnerGetStatus = useScriptRunnerGetStatus;
44483
45007
  exports.useScriptRunnerRun = useScriptRunnerRun;
44484
45008
  exports.useScriptRunnerStop = useScriptRunnerStop;
45009
+ exports.useScrubController = useScrubController;
44485
45010
  exports.useSettingsStoreCount = useSettingsStoreCount;
44486
45011
  exports.useSettingsStoreDeclareCollection = useSettingsStoreDeclareCollection;
44487
45012
  exports.useSettingsStoreDelete = useSettingsStoreDelete;