@camstack/ui-library 1.1.16 → 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
@@ -32548,6 +32782,100 @@ function useVodPlayback() {
32548
32782
  return (0, react$1.useContext)(VodPlaybackContext);
32549
32783
  }
32550
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
32551
32879
  //#region src/composites/cap-settings/recording-spans.ts
32552
32880
  /**
32553
32881
  * Merge availability ranges (across every profile) into the union of distinct
@@ -32793,8 +33121,12 @@ function axisTicks(fromMs, toMs, count) {
32793
33121
  }
32794
33122
  //#endregion
32795
33123
  //#region src/composites/cap-settings/CoverageTrack.tsx
32796
- function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
33124
+ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScrubStart, onScrubMove, onScrubEnd }) {
32797
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);
32798
33130
  const msFromEvent = (e) => {
32799
33131
  const rect = e.currentTarget.getBoundingClientRect();
32800
33132
  if (rect.width <= 0) return null;
@@ -32802,10 +33134,43 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
32802
33134
  return windowFrom + Math.min(1, Math.max(0, ratio)) * (windowTo - windowFrom);
32803
33135
  };
32804
33136
  const handleClick = (e) => {
33137
+ if (scrubEnabled) return;
32805
33138
  const ms = msFromEvent(e);
32806
33139
  if (ms === null) return;
32807
33140
  onSeek(ms);
32808
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
+ };
32809
33174
  const handleMouseMove = (e) => {
32810
33175
  const ms = msFromEvent(e);
32811
33176
  if (ms === null) return;
@@ -32829,10 +33194,15 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek }) {
32829
33194
  className: "relative",
32830
33195
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32831
33196
  "data-testid": "coverage-track",
33197
+ "data-scrubbing": dragging ? "true" : "false",
32832
33198
  onClick: handleClick,
33199
+ onPointerDown: handlePointerDown,
33200
+ onPointerMove: handlePointerMove,
33201
+ onPointerUp: handlePointerUp,
33202
+ onPointerCancel: handlePointerCancel,
32833
33203
  onMouseMove: handleMouseMove,
32834
33204
  onMouseLeave: handleMouseLeave,
32835
- 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"}`,
32836
33206
  role: "group",
32837
33207
  "aria-label": "Recorded coverage timeline",
32838
33208
  children: [spans.map((s) => {
@@ -33131,6 +33501,15 @@ var PAN_MS_PER_PX = 18e3 / 100;
33131
33501
  var EVENT_PRE_MS = 5e3;
33132
33502
  var EVENT_POST_MS = 1e4;
33133
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
+ };
33134
33513
  function RecordingTimeline({ deviceId }) {
33135
33514
  const system = useSystem();
33136
33515
  const vod = useVodPlayback();
@@ -33138,6 +33517,51 @@ function RecordingTimeline({ deviceId }) {
33138
33517
  const [playheadMs, setPlayheadMs] = (0, react$1.useState)(null);
33139
33518
  const [busyKey, setBusyKey] = (0, react$1.useState)(null);
33140
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]);
33141
33565
  const [enabledKinds, setEnabledKinds] = (0, react$1.useState)(() => new Set(ALL_KINDS));
33142
33566
  const toggleKind = (0, react$1.useCallback)((kind) => {
33143
33567
  setEnabledKinds((current) => {
@@ -33291,6 +33715,10 @@ function RecordingTimeline({ deviceId }) {
33291
33715
  const handleSeek = (0, react$1.useCallback)((clickMs) => {
33292
33716
  const target = seekTargetForSpans(clickMs, spans);
33293
33717
  if (!target) return;
33718
+ if (framePushRef.current) {
33719
+ scrubSeek(target.fromMs, "timeline");
33720
+ return;
33721
+ }
33294
33722
  const label = `Recording · ${new Date(target.fromMs).toLocaleString(void 0, {
33295
33723
  month: "short",
33296
33724
  day: "numeric",
@@ -33298,14 +33726,31 @@ function RecordingTimeline({ deviceId }) {
33298
33726
  minute: "2-digit"
33299
33727
  })}`;
33300
33728
  playWindow(`seek:${target.fromMs}`, target.fromMs, target.toMs, label, target.fromMs);
33301
- }, [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]);
33302
33743
  const handlePlayClip = (0, react$1.useCallback)((clip) => {
33744
+ if (framePushRef.current) {
33745
+ scrubSeek(clip.timestamp - EVENT_PRE_MS, "timeline");
33746
+ return;
33747
+ }
33303
33748
  const label = `${clip.label} · ${new Date(clip.timestamp).toLocaleTimeString(void 0, {
33304
33749
  hour: "2-digit",
33305
33750
  minute: "2-digit"
33306
33751
  })}`;
33307
33752
  playWindow(`event:${clip.id}`, clip.timestamp - EVENT_PRE_MS, clip.timestamp + EVENT_POST_MS, label, clip.timestamp);
33308
- }, [playWindow]);
33753
+ }, [playWindow, scrubSeek]);
33309
33754
  const handleSelectBucket = (0, react$1.useCallback)((index) => {
33310
33755
  setSelectedBucket((cur) => cur === index ? null : index);
33311
33756
  }, []);
@@ -33313,6 +33758,8 @@ function RecordingTimeline({ deviceId }) {
33313
33758
  const densityLoading = eventDensityQuery.isLoading;
33314
33759
  const drillLoading = (objectDrill.isLoading || motionDrill.isLoading || audioDrill.isLoading) && drillEnabled;
33315
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");
33316
33763
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33317
33764
  className: "space-y-3",
33318
33765
  children: [
@@ -33331,6 +33778,45 @@ function RecordingTimeline({ deviceId }) {
33331
33778
  dayBounds: computedDayBounds,
33332
33779
  onViewChange: handleViewChange
33333
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,
33334
33820
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
33335
33821
  ref: trackAreaRef,
33336
33822
  onWheel: handleWheel,
@@ -33348,8 +33834,13 @@ function RecordingTimeline({ deviceId }) {
33348
33834
  windowFrom: view.fromMs,
33349
33835
  windowTo: view.toMs,
33350
33836
  spans,
33351
- playheadMs,
33352
- onSeek: handleSeek
33837
+ playheadMs: displayPlayheadMs,
33838
+ onSeek: handleSeek,
33839
+ ...framePush ? {
33840
+ onScrubStart: handleScrubStart,
33841
+ onScrubMove: handleScrubMove,
33842
+ onScrubEnd: handleScrubEnd
33843
+ } : {}
33353
33844
  })]
33354
33845
  }),
33355
33846
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -33386,7 +33877,7 @@ function RecordingTimeline({ deviceId }) {
33386
33877
  onLoadMore: handleLoadMore
33387
33878
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
33388
33879
  className: "text-[11px] text-foreground-subtle",
33389
- 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."
33390
33881
  })
33391
33882
  ]
33392
33883
  });
@@ -38674,6 +39165,7 @@ function ImageOffIcon({ className }) {
38674
39165
  }
38675
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 }) {
38676
39167
  const registeredButtons = usePlayerToolbarButtons();
39168
+ const recordedPlayback = useRecordedPlayback();
38677
39169
  const [isPlaying, setIsPlaying] = (0, react$1.useState)(autoPlay);
38678
39170
  const [showDetections, setShowDetections] = (0, react$1.useState)(defaultShowDetections);
38679
39171
  const [showMotion, setShowMotion] = (0, react$1.useState)(defaultShowMotion);
@@ -38979,6 +39471,7 @@ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIce
38979
39471
  addIceCandidate,
38980
39472
  getIceCandidates,
38981
39473
  closeSession,
39474
+ ...recordedPlayback.bindControlChannel ? { onControlChannel: recordedPlayback.bindControlChannel } : {},
38982
39475
  ...posterUrl ? { posterUrl } : {},
38983
39476
  overlay: anyOverlay || extraOverlay || ptzOverlayVisible ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
38984
39477
  anyOverlay && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DetectionOverlay, {
@@ -43622,6 +44115,8 @@ exports.CHIP_INACTIVE = CHIP_INACTIVE;
43622
44115
  exports.CLASS_COLORS = CLASS_COLORS;
43623
44116
  exports.COLUMN_BREAKPOINT_CLASS = COLUMN_BREAKPOINT_CLASS;
43624
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;
43625
44120
  exports.CONTROL_CAP_NAMES = CONTROL_CAP_NAMES;
43626
44121
  exports.CONTROL_FILLS = CONTROL_FILLS;
43627
44122
  exports.CameraStreamPlayer = CameraStreamPlayer;
@@ -43756,9 +44251,11 @@ exports.PrivacyMaskSettings = PrivacyMaskSettings;
43756
44251
  exports.ProviderBadge = ProviderBadge;
43757
44252
  exports.PtzPanel = PtzPanel;
43758
44253
  exports.QrCode = QrCode;
44254
+ exports.RECORDED_PLAYBACK_MODES = RECORDED_PLAYBACK_MODES;
43759
44255
  exports.RIGHT = RIGHT;
43760
44256
  exports.ROLE_DESCRIPTOR = ROLE_DESCRIPTOR;
43761
44257
  exports.RadialGauge = RadialGauge;
44258
+ exports.RecordedPlaybackProvider = RecordedPlaybackProvider;
43762
44259
  exports.RecordingPanel = RecordingPanel;
43763
44260
  exports.ResponseLog = ResponseLog;
43764
44261
  exports.SECTION_BODY = SECTION_BODY;
@@ -43842,6 +44339,7 @@ exports.coverHighlight = coverHighlight;
43842
44339
  exports.createLucideIcon = createLucideIcon;
43843
44340
  exports.createSharedContext = createSharedContext;
43844
44341
  exports.createTheme = require_theme_index.createTheme$1;
44342
+ exports.cursorFractionFor = cursorFractionFor;
43845
44343
  exports.darkColors = require_theme_index.darkColors$1;
43846
44344
  exports.defaultTheme = require_theme_index.defaultTheme$1;
43847
44345
  exports.deriveDeviceKind = deriveDeviceKind;
@@ -43859,12 +44357,14 @@ exports.getPhaseVisual = getPhaseVisual;
43859
44357
  exports.groupChildrenByLayout = groupChildrenByLayout;
43860
44358
  exports.hardwareLabel = hardwareLabel;
43861
44359
  exports.humidifierTint = humidifierTint;
44360
+ exports.initialScrubState = initialScrubState;
43862
44361
  exports.isAbsentProvider = isAbsentProvider;
43863
44362
  exports.isAbsentProvider$1 = isAbsentProvider;
43864
44363
  exports.isFieldVisible = isFieldVisible;
43865
44364
  exports.lawnMowerActivityMeta = lawnMowerActivityMeta;
43866
44365
  exports.lightColors = require_theme_index.lightColors$1;
43867
44366
  exports.loadRemoteBundle = loadRemoteBundle;
44367
+ exports.makeScrubBridge = makeScrubBridge;
43868
44368
  exports.metadataEntries = metadataEntries;
43869
44369
  exports.metadataString = metadataString;
43870
44370
  exports.mirror = mirror;
@@ -43872,11 +44372,16 @@ exports.mountAddonPage = mountAddonPage;
43872
44372
  exports.nextSort = nextSort;
43873
44373
  exports.normalizeForSearch = normalizeForSearch;
43874
44374
  exports.overrideEntityIdFromLink = overrideEntityIdFromLink;
44375
+ exports.parseRecordedServerMessage = parseRecordedServerMessage;
43875
44376
  exports.providerIcons = providerIcons;
43876
44377
  exports.resolveContainerPrimary = resolveContainerPrimary;
43877
44378
  exports.resolveControlAlign = resolveControlAlign;
43878
44379
  exports.resolveDeviceControl = resolveDeviceControl;
43879
44380
  exports.resolvePrimaryChild = resolvePrimaryChild;
44381
+ exports.scrubReducer = scrubReducer;
44382
+ exports.serializeRecordedCommand = serializeRecordedCommand;
44383
+ exports.shouldCommit = shouldCommit;
44384
+ exports.shouldEmit = shouldEmit;
43880
44385
  exports.sortRows = sortRows;
43881
44386
  exports.statusIcons = statusIcons;
43882
44387
  exports.tankAlert = tankAlert;
@@ -44482,6 +44987,7 @@ exports.usePtzSavePreset = usePtzSavePreset;
44482
44987
  exports.usePtzSetAutofocus = usePtzSetAutofocus;
44483
44988
  exports.usePtzStop = usePtzStop;
44484
44989
  exports.useRebootReboot = useRebootReboot;
44990
+ exports.useRecordedPlayback = useRecordedPlayback;
44485
44991
  exports.useRecordingApplyDeviceSettingsPatch = useRecordingApplyDeviceSettingsPatch;
44486
44992
  exports.useRecordingGetAvailability = useRecordingGetAvailability;
44487
44993
  exports.useRecordingGetDaysWithRecordings = useRecordingGetDaysWithRecordings;
@@ -44500,6 +45006,7 @@ exports.useRemoteComponent = useRemoteComponent;
44500
45006
  exports.useScriptRunnerGetStatus = useScriptRunnerGetStatus;
44501
45007
  exports.useScriptRunnerRun = useScriptRunnerRun;
44502
45008
  exports.useScriptRunnerStop = useScriptRunnerStop;
45009
+ exports.useScrubController = useScrubController;
44503
45010
  exports.useSettingsStoreCount = useSettingsStoreCount;
44504
45011
  exports.useSettingsStoreDeclareCollection = useSettingsStoreDeclareCollection;
44505
45012
  exports.useSettingsStoreDelete = useSettingsStoreDelete;