@camstack/ui-library 1.2.191 → 1.2.192
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composites/clips/ClipBandBar.d.ts +16 -0
- package/dist/composites/clips/clip-band.d.ts +58 -0
- package/dist/composites/clips/clip-day-window.d.ts +21 -22
- package/dist/composites/clips/clip-still-retry.d.ts +50 -0
- package/dist/composites/clips/clip-tile-media.d.ts +25 -7
- package/dist/composites/clips/clip-view-state.d.ts +15 -2
- package/dist/generated/system-hooks.d.ts +2 -0
- package/dist/index.cjs +838 -481
- package/dist/index.js +839 -483
- package/dist/lib/format-duration.d.ts +14 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -6407,6 +6407,8 @@ var usePipelineAnalyticsGetTrackMedia = trpc.pipelineAnalytics.getTrackMedia.use
|
|
|
6407
6407
|
var usePipelineAnalyticsListTrackMedia = trpc.pipelineAnalytics.listTrackMedia.useQuery;
|
|
6408
6408
|
/** Generated alias around `trpc.pipelineAnalytics.listEventMedia.useQuery`. */
|
|
6409
6409
|
var usePipelineAnalyticsListEventMedia = trpc.pipelineAnalytics.listEventMedia.useQuery;
|
|
6410
|
+
/** Generated alias around `trpc.pipelineAnalytics.ownersWithMedia.useQuery`. */
|
|
6411
|
+
var usePipelineAnalyticsOwnersWithMedia = trpc.pipelineAnalytics.ownersWithMedia.useQuery;
|
|
6410
6412
|
/** Generated alias around `trpc.pipelineAnalytics.searchObjectEvents.useQuery`. */
|
|
6411
6413
|
var usePipelineAnalyticsSearchObjectEvents = trpc.pipelineAnalytics.searchObjectEvents.useQuery;
|
|
6412
6414
|
/** Generated alias around `trpc.pipelineAnalytics.wipeObjectEmbeddings.useMutation`. */
|
|
@@ -17794,6 +17796,201 @@ var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
|
|
|
17794
17796
|
});
|
|
17795
17797
|
Skeleton.displayName = "Skeleton";
|
|
17796
17798
|
//#endregion
|
|
17799
|
+
//#region src/composites/cap-settings/recording-timeline-model.ts
|
|
17800
|
+
/** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
|
|
17801
|
+
var KIND_PRECEDENCE = [
|
|
17802
|
+
"object",
|
|
17803
|
+
"motion",
|
|
17804
|
+
"audio"
|
|
17805
|
+
];
|
|
17806
|
+
/**
|
|
17807
|
+
* Convert server-side density rows into `DensityBucket[]` for the timeline
|
|
17808
|
+
* heatmap. Rows with total === 0 are skipped (defensive; server already omits
|
|
17809
|
+
* them). Returns a sparse, ascending-by-index array — EventHeatmap positions
|
|
17810
|
+
* each bar by `b.startMs` rather than array index, so gaps are fine.
|
|
17811
|
+
*/
|
|
17812
|
+
function densityRowsToBuckets(rows, windowFrom, bucketMs) {
|
|
17813
|
+
const buckets = [];
|
|
17814
|
+
for (const row of rows) {
|
|
17815
|
+
const counts = {
|
|
17816
|
+
motion: row.motion,
|
|
17817
|
+
object: row.object,
|
|
17818
|
+
audio: row.audio
|
|
17819
|
+
};
|
|
17820
|
+
const total = counts.motion + counts.object + counts.audio;
|
|
17821
|
+
if (total === 0) continue;
|
|
17822
|
+
const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
|
|
17823
|
+
let dominant = null;
|
|
17824
|
+
let best = -1;
|
|
17825
|
+
for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
|
|
17826
|
+
best = counts[kind];
|
|
17827
|
+
dominant = kind;
|
|
17828
|
+
}
|
|
17829
|
+
buckets.push({
|
|
17830
|
+
index,
|
|
17831
|
+
startMs: row.bucketStart,
|
|
17832
|
+
endMs: row.bucketStart + bucketMs,
|
|
17833
|
+
counts,
|
|
17834
|
+
total,
|
|
17835
|
+
dominant
|
|
17836
|
+
});
|
|
17837
|
+
}
|
|
17838
|
+
return buckets.toSorted((a, b) => a.index - b.index);
|
|
17839
|
+
}
|
|
17840
|
+
/** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
|
|
17841
|
+
function distanceToSpan(ms, span) {
|
|
17842
|
+
if (ms < span.startMs) return span.startMs - ms;
|
|
17843
|
+
if (ms > span.endMs) return ms - span.endMs;
|
|
17844
|
+
return 0;
|
|
17845
|
+
}
|
|
17846
|
+
/**
|
|
17847
|
+
* Resolve a playback window for a click at `clickMs` against recorded `spans`.
|
|
17848
|
+
* Inside a span → play from the click to that span's end. In a gap / outside →
|
|
17849
|
+
* snap to the WHOLE nearest span. No spans → null (nothing to play).
|
|
17850
|
+
* `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
|
|
17851
|
+
*/
|
|
17852
|
+
function seekTargetForSpans(clickMs, spans) {
|
|
17853
|
+
if (spans.length === 0) return null;
|
|
17854
|
+
const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
|
|
17855
|
+
if (containing) return {
|
|
17856
|
+
fromMs: clickMs,
|
|
17857
|
+
toMs: containing.endMs
|
|
17858
|
+
};
|
|
17859
|
+
let nearest = null;
|
|
17860
|
+
let bestDist = Infinity;
|
|
17861
|
+
for (const s of spans) {
|
|
17862
|
+
const d = distanceToSpan(clickMs, s);
|
|
17863
|
+
if (d < bestDist) {
|
|
17864
|
+
bestDist = d;
|
|
17865
|
+
nearest = s;
|
|
17866
|
+
}
|
|
17867
|
+
}
|
|
17868
|
+
return nearest ? {
|
|
17869
|
+
fromMs: nearest.startMs,
|
|
17870
|
+
toMs: nearest.endMs
|
|
17871
|
+
} : null;
|
|
17872
|
+
}
|
|
17873
|
+
/**
|
|
17874
|
+
* Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
|
|
17875
|
+
* (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
|
|
17876
|
+
*/
|
|
17877
|
+
function pctForMs(ms, fromMs, toMs) {
|
|
17878
|
+
const span = toMs - fromMs;
|
|
17879
|
+
if (span <= 0) return 0;
|
|
17880
|
+
const pct = (ms - fromMs) / span * 100;
|
|
17881
|
+
if (pct < 0) return 0;
|
|
17882
|
+
if (pct > 100) return 100;
|
|
17883
|
+
return pct;
|
|
17884
|
+
}
|
|
17885
|
+
/**
|
|
17886
|
+
* Build a DayWindow for the given calendar `date` using local time.
|
|
17887
|
+
* For any day strictly in the past: `fromMs` = local midnight, `toMs` =
|
|
17888
|
+
* next local midnight (exactly 24 h).
|
|
17889
|
+
* For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
|
|
17890
|
+
* window never extends into the future.
|
|
17891
|
+
*/
|
|
17892
|
+
function dayWindow(date, nowMs) {
|
|
17893
|
+
const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
|
|
17894
|
+
const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
|
|
17895
|
+
return {
|
|
17896
|
+
fromMs,
|
|
17897
|
+
toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
|
|
17898
|
+
};
|
|
17899
|
+
}
|
|
17900
|
+
/** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
|
|
17901
|
+
var ZOOM_LEVELS = [
|
|
17902
|
+
24 * 36e5,
|
|
17903
|
+
12 * 36e5,
|
|
17904
|
+
6 * 36e5,
|
|
17905
|
+
36e5,
|
|
17906
|
+
15 * 6e4
|
|
17907
|
+
];
|
|
17908
|
+
/**
|
|
17909
|
+
* Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
|
|
17910
|
+
* `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
|
|
17911
|
+
* midpoint. On today's timeline the view's right edge is "now", so zooming this
|
|
17912
|
+
* way keeps the most recent footage pinned to the right instead of drifting to
|
|
17913
|
+
* a midday range that no longer contains now. Panning then moves back/forward.
|
|
17914
|
+
*
|
|
17915
|
+
* Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
|
|
17916
|
+
* show a wider window than the day holds — e.g. early morning with little
|
|
17917
|
+
* elapsed time).
|
|
17918
|
+
*/
|
|
17919
|
+
function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
|
|
17920
|
+
if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
|
|
17921
|
+
const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
|
|
17922
|
+
return {
|
|
17923
|
+
fromMs: toMs - levelMs,
|
|
17924
|
+
toMs
|
|
17925
|
+
};
|
|
17926
|
+
}
|
|
17927
|
+
/**
|
|
17928
|
+
* Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
|
|
17929
|
+
*/
|
|
17930
|
+
function panWindow(win, dayBounds, deltaMs) {
|
|
17931
|
+
const width = win.toMs - win.fromMs;
|
|
17932
|
+
let fromMs = win.fromMs + deltaMs;
|
|
17933
|
+
let toMs = win.toMs + deltaMs;
|
|
17934
|
+
if (fromMs < dayBounds.fromMs) {
|
|
17935
|
+
fromMs = dayBounds.fromMs;
|
|
17936
|
+
toMs = fromMs + width;
|
|
17937
|
+
}
|
|
17938
|
+
if (toMs > dayBounds.toMs) {
|
|
17939
|
+
toMs = dayBounds.toMs;
|
|
17940
|
+
fromMs = toMs - width;
|
|
17941
|
+
}
|
|
17942
|
+
return {
|
|
17943
|
+
fromMs,
|
|
17944
|
+
toMs
|
|
17945
|
+
};
|
|
17946
|
+
}
|
|
17947
|
+
/** Candidate bucket sizes in ms (coarsest first). */
|
|
17948
|
+
var BUCKET_CANDIDATES = [
|
|
17949
|
+
15 * 6e4,
|
|
17950
|
+
5 * 6e4,
|
|
17951
|
+
6e4,
|
|
17952
|
+
3e4,
|
|
17953
|
+
15e3,
|
|
17954
|
+
5e3
|
|
17955
|
+
];
|
|
17956
|
+
/**
|
|
17957
|
+
* Choose a sensible heatmap bucket size for a visible span of `spanMs`,
|
|
17958
|
+
* targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
|
|
17959
|
+
*/
|
|
17960
|
+
function bucketMsForSpan(spanMs) {
|
|
17961
|
+
const targetBucket = spanMs / 100;
|
|
17962
|
+
for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
|
|
17963
|
+
return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
|
|
17964
|
+
}
|
|
17965
|
+
/**
|
|
17966
|
+
* Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
|
|
17967
|
+
* - `pct` is via pctForMs (first=0, last=100).
|
|
17968
|
+
* - `label` is a localised HH:MM clock string.
|
|
17969
|
+
*/
|
|
17970
|
+
function axisTicks(fromMs, toMs, count) {
|
|
17971
|
+
if (count <= 1) return [{
|
|
17972
|
+
pct: 0,
|
|
17973
|
+
label: new Date(fromMs).toLocaleTimeString(void 0, {
|
|
17974
|
+
hour: "2-digit",
|
|
17975
|
+
minute: "2-digit"
|
|
17976
|
+
})
|
|
17977
|
+
}];
|
|
17978
|
+
const ticks = [];
|
|
17979
|
+
for (let i = 0; i < count; i++) {
|
|
17980
|
+
const ms = fromMs + i / (count - 1) * (toMs - fromMs);
|
|
17981
|
+
const pct = pctForMs(ms, fromMs, toMs);
|
|
17982
|
+
const label = new Date(ms).toLocaleTimeString(void 0, {
|
|
17983
|
+
hour: "2-digit",
|
|
17984
|
+
minute: "2-digit"
|
|
17985
|
+
});
|
|
17986
|
+
ticks.push({
|
|
17987
|
+
pct,
|
|
17988
|
+
label
|
|
17989
|
+
});
|
|
17990
|
+
}
|
|
17991
|
+
return ticks;
|
|
17992
|
+
}
|
|
17993
|
+
//#endregion
|
|
17797
17994
|
//#region src/hooks/use-is-mobile.ts
|
|
17798
17995
|
var MOBILE_QUERY = "(max-width: 767px)";
|
|
17799
17996
|
/**
|
|
@@ -18430,6 +18627,266 @@ function ErrorBox({ message, title, onRetry, action, variant = "danger", classNa
|
|
|
18430
18627
|
});
|
|
18431
18628
|
}
|
|
18432
18629
|
//#endregion
|
|
18630
|
+
//#region src/composites/cap-settings/EventHeatmap.tsx
|
|
18631
|
+
var KIND_BG = {
|
|
18632
|
+
motion: "bg-emerald-500",
|
|
18633
|
+
object: "bg-violet-500",
|
|
18634
|
+
audio: "bg-orange-500"
|
|
18635
|
+
};
|
|
18636
|
+
function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
|
|
18637
|
+
const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
|
|
18638
|
+
const firstBucket = buckets[0];
|
|
18639
|
+
const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
|
|
18640
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
18641
|
+
className: "relative h-6 rounded bg-background/40",
|
|
18642
|
+
children: buckets.map((b) => {
|
|
18643
|
+
if (b.total === 0 || b.dominant === null) return null;
|
|
18644
|
+
const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
|
|
18645
|
+
const left = pctForMs(b.startMs, windowFrom, windowTo);
|
|
18646
|
+
const selected = b.index === selectedIndex;
|
|
18647
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
18648
|
+
type: "button",
|
|
18649
|
+
"data-testid": "heatmap-bar",
|
|
18650
|
+
title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
|
|
18651
|
+
hour: "2-digit",
|
|
18652
|
+
minute: "2-digit"
|
|
18653
|
+
})} · motion ${b.counts.motion} · object ${b.counts.object}`,
|
|
18654
|
+
onClick: () => onSelectBucket(b.index),
|
|
18655
|
+
className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
|
|
18656
|
+
style: {
|
|
18657
|
+
left: `${left}%`,
|
|
18658
|
+
width: `${Math.max(.6, bucketWidthPct)}%`,
|
|
18659
|
+
height: `${heightPct}%`
|
|
18660
|
+
}
|
|
18661
|
+
}, b.index);
|
|
18662
|
+
})
|
|
18663
|
+
});
|
|
18664
|
+
}
|
|
18665
|
+
//#endregion
|
|
18666
|
+
//#region src/composites/cap-settings/TimelineControls.tsx
|
|
18667
|
+
/** Format a Date as "YYYY-MM-DD" for <input type="date">. */
|
|
18668
|
+
function formatDateInput(date) {
|
|
18669
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
18670
|
+
}
|
|
18671
|
+
/** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
|
|
18672
|
+
function parseDateInput(value) {
|
|
18673
|
+
const parts = value.split("-");
|
|
18674
|
+
const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
|
|
18675
|
+
const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
|
|
18676
|
+
const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
|
|
18677
|
+
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
|
|
18678
|
+
return new Date(y, m - 1, d);
|
|
18679
|
+
}
|
|
18680
|
+
/**
|
|
18681
|
+
* Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
|
|
18682
|
+
* timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
|
|
18683
|
+
* the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
|
|
18684
|
+
* instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
|
|
18685
|
+
* window earlier/later within the day (the discoverable equivalent of the
|
|
18686
|
+
* horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
|
|
18687
|
+
* navigated back and forth without a mouse wheel.
|
|
18688
|
+
*/
|
|
18689
|
+
function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
|
|
18690
|
+
const handleDateChange = (e) => {
|
|
18691
|
+
const parsed = parseDateInput(e.target.value);
|
|
18692
|
+
if (parsed !== null) onDayChange(parsed);
|
|
18693
|
+
};
|
|
18694
|
+
const viewSpan = view.toMs - view.fromMs;
|
|
18695
|
+
const currentLevelIdx = (() => {
|
|
18696
|
+
let best = 0;
|
|
18697
|
+
let bestDiff = Infinity;
|
|
18698
|
+
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
|
18699
|
+
const level = ZOOM_LEVELS[i];
|
|
18700
|
+
if (level === void 0) continue;
|
|
18701
|
+
const diff = Math.abs(level - viewSpan);
|
|
18702
|
+
if (diff < bestDiff) {
|
|
18703
|
+
bestDiff = diff;
|
|
18704
|
+
best = i;
|
|
18705
|
+
}
|
|
18706
|
+
}
|
|
18707
|
+
return best;
|
|
18708
|
+
})();
|
|
18709
|
+
const rightEdgeMs = view.toMs;
|
|
18710
|
+
const handleZoomIn = () => {
|
|
18711
|
+
const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
|
|
18712
|
+
if (nextLevel === void 0) return;
|
|
18713
|
+
onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
|
|
18714
|
+
};
|
|
18715
|
+
const handleZoomOut = () => {
|
|
18716
|
+
const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
|
|
18717
|
+
if (nextLevel === void 0) return;
|
|
18718
|
+
onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
|
|
18719
|
+
};
|
|
18720
|
+
const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
|
|
18721
|
+
const canZoomOut = currentLevelIdx > 0;
|
|
18722
|
+
const panStepMs = Math.max(1, Math.round(viewSpan * .9));
|
|
18723
|
+
const canPanPrev = view.fromMs > dayBounds.fromMs;
|
|
18724
|
+
const canPanNext = view.toMs < dayBounds.toMs;
|
|
18725
|
+
const handlePanPrev = () => {
|
|
18726
|
+
onViewChange(panWindow(view, dayBounds, -panStepMs));
|
|
18727
|
+
};
|
|
18728
|
+
const handlePanNext = () => {
|
|
18729
|
+
onViewChange(panWindow(view, dayBounds, panStepMs));
|
|
18730
|
+
};
|
|
18731
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
18732
|
+
className: "flex items-center gap-2",
|
|
18733
|
+
children: [
|
|
18734
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
18735
|
+
type: "date",
|
|
18736
|
+
value: formatDateInput(day),
|
|
18737
|
+
max: formatDateInput(/* @__PURE__ */ new Date()),
|
|
18738
|
+
onChange: handleDateChange,
|
|
18739
|
+
className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
|
|
18740
|
+
"aria-label": "Select recording day"
|
|
18741
|
+
}),
|
|
18742
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
18743
|
+
className: "ml-auto flex items-center gap-1",
|
|
18744
|
+
children: [
|
|
18745
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
18746
|
+
type: "button",
|
|
18747
|
+
onClick: handlePanPrev,
|
|
18748
|
+
disabled: !canPanPrev,
|
|
18749
|
+
title: "Earlier (pan back)",
|
|
18750
|
+
"aria-label": "Pan earlier",
|
|
18751
|
+
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
18752
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
|
|
18753
|
+
}),
|
|
18754
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
18755
|
+
type: "button",
|
|
18756
|
+
onClick: handlePanNext,
|
|
18757
|
+
disabled: !canPanNext,
|
|
18758
|
+
title: "Later (pan forward)",
|
|
18759
|
+
"aria-label": "Pan later",
|
|
18760
|
+
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
18761
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
|
|
18762
|
+
}),
|
|
18763
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
18764
|
+
className: "mx-0.5 h-3 w-px bg-border",
|
|
18765
|
+
"aria-hidden": "true"
|
|
18766
|
+
}),
|
|
18767
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
18768
|
+
type: "button",
|
|
18769
|
+
onClick: handleZoomOut,
|
|
18770
|
+
disabled: !canZoomOut,
|
|
18771
|
+
title: "Zoom out",
|
|
18772
|
+
"aria-label": "Zoom out",
|
|
18773
|
+
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
18774
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
|
|
18775
|
+
}),
|
|
18776
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
18777
|
+
type: "button",
|
|
18778
|
+
onClick: handleZoomIn,
|
|
18779
|
+
disabled: !canZoomIn,
|
|
18780
|
+
title: "Zoom in",
|
|
18781
|
+
"aria-label": "Zoom in",
|
|
18782
|
+
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
18783
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
|
|
18784
|
+
})
|
|
18785
|
+
]
|
|
18786
|
+
}),
|
|
18787
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
18788
|
+
className: "text-[9px] text-foreground-subtle",
|
|
18789
|
+
children: zoomLabel(viewSpan)
|
|
18790
|
+
})
|
|
18791
|
+
]
|
|
18792
|
+
});
|
|
18793
|
+
}
|
|
18794
|
+
function zoomLabel(spanMs) {
|
|
18795
|
+
const h = spanMs / 36e5;
|
|
18796
|
+
if (h >= 24) return "24h";
|
|
18797
|
+
if (h >= 1) return `${Math.round(h)}h`;
|
|
18798
|
+
const m = spanMs / 6e4;
|
|
18799
|
+
return `${Math.round(m)}m`;
|
|
18800
|
+
}
|
|
18801
|
+
//#endregion
|
|
18802
|
+
//#region src/composites/clips/ClipBandBar.tsx
|
|
18803
|
+
/**
|
|
18804
|
+
* The clips section's timebar — the recording section's own controls, driving
|
|
18805
|
+
* a clip list instead of a player (D564).
|
|
18806
|
+
*
|
|
18807
|
+
* *"Possiamo utilizzare lo stesso componente della sezione recording con la
|
|
18808
|
+
* timebar dove selezionare un periodo e mostrare le clip solo di quella banda
|
|
18809
|
+
* selezionata, espandibile."* — the operator, 2026-09-20. So this is not a
|
|
18810
|
+
* second timebar: it is `TimelineControls` and `EventHeatmap`, the two pieces
|
|
18811
|
+
* `RecordingTimeline` is itself built from, over the same
|
|
18812
|
+
* `recording-timeline-model` (`dayWindow`, `panWindow`,
|
|
18813
|
+
* `zoomWindowAnchoredRight`, `bucketMsForSpan`, `axisTicks`). One
|
|
18814
|
+
* implementation, one model, two surfaces.
|
|
18815
|
+
*
|
|
18816
|
+
* ## What it does NOT take from the recording timeline
|
|
18817
|
+
*
|
|
18818
|
+
* `CoverageTrack` stays behind. There it is the seek surface — a click drives
|
|
18819
|
+
* the hero player through the `camstack-control` channel or an HLS VOD
|
|
18820
|
+
* request. A copy here would either be a dead bar or a SECOND thing claiming
|
|
18821
|
+
* to move the playhead, and two components holding two ideas of the same
|
|
18822
|
+
* position is the defect this repo keeps paying for. The clips section plays a
|
|
18823
|
+
* clip through `ClipPanel`, which is its own, already-shipped path.
|
|
18824
|
+
*
|
|
18825
|
+
* ## This component is presentational
|
|
18826
|
+
*
|
|
18827
|
+
* It holds no state. The day, the visible window and the picked bucket all
|
|
18828
|
+
* live in `ClipsSection`, because that is where the band is turned into the
|
|
18829
|
+
* `listClips` argument — a bar that kept its own copy of the selection would
|
|
18830
|
+
* be the second authority again, one component down.
|
|
18831
|
+
*/
|
|
18832
|
+
/**
|
|
18833
|
+
* How many ms one pixel of horizontal wheel delta pans.
|
|
18834
|
+
*
|
|
18835
|
+
* The same calibration `RecordingTimeline` uses — a standard wheel tick
|
|
18836
|
+
* (~100 px of `deltaX`) pans ~30 min. Two timebars that panned at different
|
|
18837
|
+
* rates would read as two different controls.
|
|
18838
|
+
*/
|
|
18839
|
+
var PAN_MS_PER_PX$1 = 18e3 / 100;
|
|
18840
|
+
function ClipBandBar({ day, onDayChange, view, dayBounds, onViewChange, buckets, selectedBucketIndex, onSelectBucket, densityLoading }) {
|
|
18841
|
+
const trackAreaRef = (0, react$1.useRef)(null);
|
|
18842
|
+
const handleWheel = (0, react$1.useCallback)((e) => {
|
|
18843
|
+
const deltaX = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
|
|
18844
|
+
if (deltaX === 0) return;
|
|
18845
|
+
e.preventDefault();
|
|
18846
|
+
onViewChange(panWindow(view, dayBounds, deltaX * PAN_MS_PER_PX$1));
|
|
18847
|
+
}, [
|
|
18848
|
+
view,
|
|
18849
|
+
dayBounds,
|
|
18850
|
+
onViewChange
|
|
18851
|
+
]);
|
|
18852
|
+
const ticks = axisTicks(view.fromMs, view.toMs, 5);
|
|
18853
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
18854
|
+
className: "space-y-1",
|
|
18855
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimelineControls, {
|
|
18856
|
+
day,
|
|
18857
|
+
onDayChange,
|
|
18858
|
+
view,
|
|
18859
|
+
dayBounds,
|
|
18860
|
+
onViewChange
|
|
18861
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
18862
|
+
ref: trackAreaRef,
|
|
18863
|
+
onWheel: handleWheel,
|
|
18864
|
+
className: "space-y-1",
|
|
18865
|
+
children: [
|
|
18866
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
18867
|
+
className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
|
|
18868
|
+
children: "Events · density"
|
|
18869
|
+
}),
|
|
18870
|
+
densityLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-6 animate-pulse rounded bg-background/40" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventHeatmap, {
|
|
18871
|
+
windowFrom: view.fromMs,
|
|
18872
|
+
windowTo: view.toMs,
|
|
18873
|
+
buckets,
|
|
18874
|
+
selectedIndex: selectedBucketIndex,
|
|
18875
|
+
onSelectBucket
|
|
18876
|
+
}),
|
|
18877
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
18878
|
+
className: "relative h-3",
|
|
18879
|
+
children: ticks.map((tick) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
18880
|
+
className: "absolute -translate-x-1/2 text-[9px] text-foreground-subtle",
|
|
18881
|
+
style: { left: `${String(tick.pct)}%` },
|
|
18882
|
+
children: tick.label
|
|
18883
|
+
}, tick.pct))
|
|
18884
|
+
})
|
|
18885
|
+
]
|
|
18886
|
+
})]
|
|
18887
|
+
});
|
|
18888
|
+
}
|
|
18889
|
+
//#endregion
|
|
18433
18890
|
//#region src/contexts/vod-playback.tsx
|
|
18434
18891
|
/**
|
|
18435
18892
|
* VodPlayback — shared context coordinating recorded (VOD) playback between a
|
|
@@ -18588,24 +19045,58 @@ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
|
|
|
18588
19045
|
});
|
|
18589
19046
|
}
|
|
18590
19047
|
//#endregion
|
|
19048
|
+
//#region src/lib/format-duration.ts
|
|
19049
|
+
/**
|
|
19050
|
+
* How long something lasted, the way this product says it.
|
|
19051
|
+
*
|
|
19052
|
+
* Promoted verbatim from `composites/cap-settings/RecordingPanel.tsx`, which
|
|
19053
|
+
* had been the only place answering this question about MEDIA. It is now the
|
|
19054
|
+
* only place, so a clip's length and a recording's retention read the same.
|
|
19055
|
+
*
|
|
19056
|
+
* Two other private formatters stay where they are, on purpose:
|
|
19057
|
+
* `config-form-field.tsx` formats a config VALUE (sub-second `ms`, `—` for a
|
|
19058
|
+
* value that is not a number), and `scene-monitor-copy.ts`'s `humanDuration`
|
|
19059
|
+
* is deliberately single-unit and coarse for a sentence. Neither is a media
|
|
19060
|
+
* length, and folding them in here would change what they say.
|
|
19061
|
+
*/
|
|
19062
|
+
function formatDurationMs(ms) {
|
|
19063
|
+
const totalSec = Math.round(ms / 1e3);
|
|
19064
|
+
const h = Math.floor(totalSec / 3600);
|
|
19065
|
+
const m = Math.floor(totalSec % 3600 / 60);
|
|
19066
|
+
const s = totalSec % 60;
|
|
19067
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
19068
|
+
if (m > 0) return `${m}m ${s}s`;
|
|
19069
|
+
return `${s}s`;
|
|
19070
|
+
}
|
|
19071
|
+
//#endregion
|
|
18591
19072
|
//#region src/composites/clips/clip-tile-media.ts
|
|
18592
19073
|
/**
|
|
18593
|
-
* Why no still exists, in words
|
|
18594
|
-
*
|
|
19074
|
+
* Why no still exists, in words, keyed by the refusal CODE.
|
|
19075
|
+
*
|
|
19076
|
+
* A code this table does not know falls back to the provider's own prose, so a
|
|
19077
|
+
* firmware inventing a refusal reaches the operator rather than becoming a
|
|
19078
|
+
* blank tile.
|
|
18595
19079
|
*/
|
|
18596
19080
|
var REFUSAL_TEXT = {
|
|
18597
|
-
sleeping: "The camera is asleep — a still would cost a wake",
|
|
18598
|
-
"camera-refused":
|
|
18599
|
-
"no-keyframe": "No decodable frame inside this window",
|
|
18600
|
-
unsupported: "This source cannot produce stills",
|
|
18601
|
-
"queue-full": "
|
|
18602
|
-
"
|
|
19081
|
+
sleeping: () => "The camera is asleep — a still would cost a wake",
|
|
19082
|
+
"camera-refused": (reason) => `The camera refused — ${reason}`,
|
|
19083
|
+
"no-keyframe": () => "No decodable frame inside this window",
|
|
19084
|
+
unsupported: () => "This source cannot produce stills",
|
|
19085
|
+
"queue-full": () => "Waiting for the camera — its still queue is full",
|
|
19086
|
+
"camera-backoff": () => "The camera is resting after refusing — trying again shortly",
|
|
19087
|
+
"no-catalog-row": () => "This clip is no longer in the catalog",
|
|
19088
|
+
"unknown-device": () => "This camera is not served here",
|
|
19089
|
+
"uid-missing": () => "The camera’s id is not known yet"
|
|
18603
19090
|
};
|
|
18604
|
-
function
|
|
19091
|
+
function wordsFor(code, reason) {
|
|
19092
|
+
const known = code === void 0 ? void 0 : REFUSAL_TEXT[code];
|
|
19093
|
+
return known === void 0 ? reason : known(reason);
|
|
19094
|
+
}
|
|
19095
|
+
function refusal(reason, code) {
|
|
18605
19096
|
return {
|
|
18606
19097
|
kind: "unavailable",
|
|
18607
19098
|
reason,
|
|
18608
|
-
text:
|
|
19099
|
+
text: wordsFor(code, reason)
|
|
18609
19100
|
};
|
|
18610
19101
|
}
|
|
18611
19102
|
/** What to draw for one clip's tile. */
|
|
@@ -18614,7 +19105,7 @@ function resolveTileMedia(clip) {
|
|
|
18614
19105
|
kind: "vouched",
|
|
18615
19106
|
url: clip.thumbnail
|
|
18616
19107
|
};
|
|
18617
|
-
if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
|
|
19108
|
+
if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason, clip.thumbnailUnavailable.reason);
|
|
18618
19109
|
if (clip.thumbnailMint !== void 0) return {
|
|
18619
19110
|
kind: "mint",
|
|
18620
19111
|
url: clip.thumbnailMint
|
|
@@ -18625,26 +19116,32 @@ function resolveTileMedia(clip) {
|
|
|
18625
19116
|
};
|
|
18626
19117
|
}
|
|
18627
19118
|
/**
|
|
18628
|
-
* A
|
|
19119
|
+
* A 204, as something the tile can draw.
|
|
18629
19120
|
*
|
|
18630
|
-
*
|
|
18631
|
-
*
|
|
18632
|
-
*
|
|
18633
|
-
*
|
|
19121
|
+
* `deferred` is the ONLY answer that comes back, and only because the server
|
|
19122
|
+
* said so. Everything else — an unknown disposition, an absent one, a code
|
|
19123
|
+
* this build has never heard of — is final, so the failure mode of a
|
|
19124
|
+
* vocabulary that moves is a tile that stops early, never one that asks for
|
|
19125
|
+
* ever.
|
|
18634
19126
|
*/
|
|
18635
|
-
function
|
|
18636
|
-
|
|
19127
|
+
function describeRefusal(headers) {
|
|
19128
|
+
if (headers.disposition === "deferred") return {
|
|
19129
|
+
kind: "deferred",
|
|
19130
|
+
reason: headers.reason,
|
|
19131
|
+
text: wordsFor(headers.code, headers.reason),
|
|
19132
|
+
retryAfterMs: headers.retryAfterMs ?? null
|
|
19133
|
+
};
|
|
19134
|
+
return refusal(headers.reason, headers.code);
|
|
18637
19135
|
}
|
|
18638
19136
|
//#endregion
|
|
18639
19137
|
//#region src/composites/clips/clip-thumbnail-fetch.ts
|
|
18640
19138
|
/**
|
|
18641
|
-
* Asking a `thumbnailMint` route for a still — and hearing
|
|
19139
|
+
* Asking a `thumbnailMint` route for a still — and hearing what it says.
|
|
18642
19140
|
*
|
|
18643
|
-
* The route answers **200** with a JPEG or **204** with
|
|
18644
|
-
* `
|
|
18645
|
-
*
|
|
18646
|
-
*
|
|
18647
|
-
* anonymous broken tile. So a mint goes through `fetch`.
|
|
19141
|
+
* The route answers **200** with a JPEG or **204** with a refusal. An
|
|
19142
|
+
* `<img src>` cannot read a 204's headers: it fires `onerror` with nothing to
|
|
19143
|
+
* say, turning a named refusal into an anonymous broken tile. So a mint goes
|
|
19144
|
+
* through `fetch`.
|
|
18648
19145
|
*
|
|
18649
19146
|
* **Nothing is appended to the URL.** Both data-plane routes are registered
|
|
18650
19147
|
* `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
|
|
@@ -18652,8 +19149,18 @@ function latchMintRefusal(reason) {
|
|
|
18652
19149
|
* credential (D549 decision 22) — it may sit in history or a `Referer` and
|
|
18653
19150
|
* open nothing.
|
|
18654
19151
|
*
|
|
18655
|
-
*
|
|
18656
|
-
*
|
|
19152
|
+
* ## Three headers, one of which decides everything (D563)
|
|
19153
|
+
*
|
|
19154
|
+
* `x-camstack-reason-code` is the token the tile keys its words on,
|
|
19155
|
+
* `x-camstack-reason` is the camera's own prose, and
|
|
19156
|
+
* `x-camstack-disposition` says whether to come back. This module reads them;
|
|
19157
|
+
* it does not classify them. A response with no disposition — every
|
|
19158
|
+
* provider-reolink build before D563 — is FINAL, which is precisely the
|
|
19159
|
+
* behaviour that shipped.
|
|
19160
|
+
*
|
|
19161
|
+
* A thrown fetch is DEFERRED: nothing refused anything, one request lost a
|
|
19162
|
+
* race, and a tile that goes permanently blank for that is the same defect
|
|
19163
|
+
* with a different cause.
|
|
18657
19164
|
*/
|
|
18658
19165
|
/**
|
|
18659
19166
|
* Ask a mint route for one still.
|
|
@@ -18667,19 +19174,56 @@ async function fetchMintedThumbnail(url, fetcher) {
|
|
|
18667
19174
|
try {
|
|
18668
19175
|
response = await fetcher(url, { credentials: "same-origin" });
|
|
18669
19176
|
} catch (err) {
|
|
18670
|
-
return
|
|
19177
|
+
return deferredNetworkFailure(err);
|
|
18671
19178
|
}
|
|
18672
|
-
if (response.status === 204) return
|
|
18673
|
-
if (!response.ok) return
|
|
19179
|
+
if (response.status === 204) return describeRefusal(refusalHeadersOf(response));
|
|
19180
|
+
if (!response.ok) return describeRefusal({
|
|
19181
|
+
code: void 0,
|
|
19182
|
+
reason: `HTTP ${String(response.status)}`,
|
|
19183
|
+
disposition: void 0
|
|
19184
|
+
});
|
|
18674
19185
|
try {
|
|
18675
19186
|
return {
|
|
18676
19187
|
kind: "image",
|
|
18677
19188
|
blob: await response.blob()
|
|
18678
19189
|
};
|
|
18679
19190
|
} catch (err) {
|
|
18680
|
-
return
|
|
19191
|
+
return deferredNetworkFailure(err);
|
|
18681
19192
|
}
|
|
18682
19193
|
}
|
|
19194
|
+
function refusalHeadersOf(response) {
|
|
19195
|
+
const retryAfter = response.headers.get("retry-after");
|
|
19196
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
19197
|
+
return {
|
|
19198
|
+
code: response.headers.get(_camstack_types.CLIP_STILL_REASON_CODE_HEADER) ?? void 0,
|
|
19199
|
+
reason: response.headers.get(_camstack_types.CLIP_STILL_REASON_HEADER) ?? "no reason given",
|
|
19200
|
+
disposition: response.headers.get(_camstack_types.CLIP_STILL_DISPOSITION_HEADER) ?? void 0,
|
|
19201
|
+
retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null
|
|
19202
|
+
};
|
|
19203
|
+
}
|
|
19204
|
+
function deferredNetworkFailure(err) {
|
|
19205
|
+
return describeRefusal({
|
|
19206
|
+
code: void 0,
|
|
19207
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
19208
|
+
disposition: "deferred"
|
|
19209
|
+
});
|
|
19210
|
+
}
|
|
19211
|
+
/** First wait, doubling from there. */
|
|
19212
|
+
var STILL_RETRY_BASE_MS = 4e3;
|
|
19213
|
+
/** No single wait is longer than this, whatever the server hints. */
|
|
19214
|
+
var STILL_RETRY_MAX_WAIT_MS = 3e4;
|
|
19215
|
+
/** ±25 %: enough to break a synchronised herd, not enough to stop being a
|
|
19216
|
+
* schedule. */
|
|
19217
|
+
var JITTER_FRACTION = .25;
|
|
19218
|
+
/** The next wait, or `null` when the budget is spent. */
|
|
19219
|
+
function planStillRetry(input) {
|
|
19220
|
+
if (input.attempt >= 4 || input.attempt < 0) return null;
|
|
19221
|
+
const curve = STILL_RETRY_BASE_MS * 2 ** input.attempt;
|
|
19222
|
+
const chosen = Math.max(curve, input.serverHintMs ?? 0);
|
|
19223
|
+
const bounded = Math.min(chosen, STILL_RETRY_MAX_WAIT_MS);
|
|
19224
|
+
const jitter = 1 + (input.random() * 2 - 1) * JITTER_FRACTION;
|
|
19225
|
+
return { waitMs: Math.round(bounded * jitter) };
|
|
19226
|
+
}
|
|
18683
19227
|
//#endregion
|
|
18684
19228
|
//#region src/composites/clips/ClipTile.tsx
|
|
18685
19229
|
/**
|
|
@@ -18710,7 +19254,7 @@ function clockOf(ms) {
|
|
|
18710
19254
|
});
|
|
18711
19255
|
}
|
|
18712
19256
|
/**
|
|
18713
|
-
* Ask a mint route
|
|
19257
|
+
* Ask a mint route, and come back only for the answers that say to.
|
|
18714
19258
|
*
|
|
18715
19259
|
* The offer is memoised on the three fields it is derived from, not rebuilt
|
|
18716
19260
|
* per render: `resolveTileMedia` returns a fresh object every call, and an
|
|
@@ -18718,8 +19262,12 @@ function clockOf(ms) {
|
|
|
18718
19262
|
* effect sets state, is an infinite render loop. It was one here, and it hung
|
|
18719
19263
|
* the suite rather than failing it.
|
|
18720
19264
|
*
|
|
18721
|
-
*
|
|
18722
|
-
* "
|
|
19265
|
+
* A FINAL refusal is latched for the life of the tile: a camera that just said
|
|
19266
|
+
* "asleep" is not asked again behind the operator's back. A DEFERRED one is
|
|
19267
|
+
* not a refusal at all — the still is queued — so the tile keeps showing
|
|
19268
|
+
* loading and re-asks on `planStillRetry`'s bounded, jittered schedule. When
|
|
19269
|
+
* that budget runs out the deferred answer becomes what the tile SAYS, so the
|
|
19270
|
+
* end state is words, never a blank box and never another request (D563).
|
|
18723
19271
|
*/
|
|
18724
19272
|
function useMintedStill(clip) {
|
|
18725
19273
|
const offered = (0, react$1.useMemo)(() => resolveTileMedia(clip), [
|
|
@@ -18734,7 +19282,8 @@ function useMintedStill(clip) {
|
|
|
18734
19282
|
if (mintUrl === null || typeof fetch !== "function") return void 0;
|
|
18735
19283
|
let objectUrl = null;
|
|
18736
19284
|
let cancelled = false;
|
|
18737
|
-
|
|
19285
|
+
let timer;
|
|
19286
|
+
const ask = async (attempt) => {
|
|
18738
19287
|
const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
|
|
18739
19288
|
if (cancelled) return;
|
|
18740
19289
|
if (answer.kind === "image") {
|
|
@@ -18745,10 +19294,31 @@ function useMintedStill(clip) {
|
|
|
18745
19294
|
});
|
|
18746
19295
|
return;
|
|
18747
19296
|
}
|
|
18748
|
-
|
|
18749
|
-
|
|
19297
|
+
if (answer.kind !== "deferred") {
|
|
19298
|
+
setLatched(answer);
|
|
19299
|
+
return;
|
|
19300
|
+
}
|
|
19301
|
+
const plan = planStillRetry({
|
|
19302
|
+
attempt,
|
|
19303
|
+
serverHintMs: answer.retryAfterMs,
|
|
19304
|
+
random: Math.random
|
|
19305
|
+
});
|
|
19306
|
+
if (plan === null) {
|
|
19307
|
+
setLatched({
|
|
19308
|
+
kind: "unavailable",
|
|
19309
|
+
reason: answer.reason,
|
|
19310
|
+
text: answer.text
|
|
19311
|
+
});
|
|
19312
|
+
return;
|
|
19313
|
+
}
|
|
19314
|
+
timer = setTimeout(() => {
|
|
19315
|
+
ask(attempt + 1);
|
|
19316
|
+
}, plan.waitMs);
|
|
19317
|
+
};
|
|
19318
|
+
ask(0);
|
|
18750
19319
|
return () => {
|
|
18751
19320
|
cancelled = true;
|
|
19321
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
18752
19322
|
if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
|
|
18753
19323
|
};
|
|
18754
19324
|
}, [mintUrl]);
|
|
@@ -18772,16 +19342,27 @@ function ClipTile({ clip, selected, onSelect }) {
|
|
|
18772
19342
|
src: media.url,
|
|
18773
19343
|
alt: "",
|
|
18774
19344
|
className: "h-full w-full object-cover"
|
|
18775
|
-
}) : media.kind === "mint"
|
|
19345
|
+
}) : media.kind === "mint" || media.kind === "deferred" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
19346
|
+
role: "status",
|
|
19347
|
+
"aria-label": media.kind === "mint" ? "Loading the still" : media.text,
|
|
19348
|
+
className: "h-full w-full",
|
|
19349
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-full w-full rounded-none" })
|
|
19350
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
18776
19351
|
className: "px-1 text-center leading-tight",
|
|
18777
19352
|
children: media.text
|
|
18778
19353
|
})
|
|
18779
19354
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
18780
19355
|
className: "min-w-0 flex-1 space-y-1",
|
|
18781
19356
|
children: [
|
|
18782
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
18783
|
-
className: "
|
|
18784
|
-
children:
|
|
19357
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
19358
|
+
className: "flex items-baseline gap-2",
|
|
19359
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
19360
|
+
className: "text-sm font-medium text-foreground",
|
|
19361
|
+
children: clockOf(clip.timeRange.startMs)
|
|
19362
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
19363
|
+
className: "text-[11px] text-foreground-subtle",
|
|
19364
|
+
children: clip.inProgress === true ? "Recording…" : formatDurationMs(clip.timeRange.endMs - clip.timeRange.startMs)
|
|
19365
|
+
})]
|
|
18785
19366
|
}),
|
|
18786
19367
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
18787
19368
|
className: "text-[10px] text-foreground-subtle",
|
|
@@ -18808,6 +19389,29 @@ function ClipTile({ clip, selected, onSelect }) {
|
|
|
18808
19389
|
}
|
|
18809
19390
|
//#endregion
|
|
18810
19391
|
//#region src/composites/clips/clip-day-window.ts
|
|
19392
|
+
/**
|
|
19393
|
+
* LOCAL calendar-day arithmetic for the clips browser.
|
|
19394
|
+
*
|
|
19395
|
+
* The browser no longer NAVIGATES days — it follows a band selected on the
|
|
19396
|
+
* recording timebar (D564) — but the local day survived the move as the unit
|
|
19397
|
+
* the COST is counted in: a Reolink search is clamped to one camera-local day
|
|
19398
|
+
* per call, so `clip-band.ts` counts a band's local days to decide whether it
|
|
19399
|
+
* may be asked for. The window builder this file used to own went with the day
|
|
19400
|
+
* picker; `recording-timeline-model.ts`'s `dayWindow(date, nowMs)` is the one
|
|
19401
|
+
* that builds windows now, and one directory holding two functions of that
|
|
19402
|
+
* name was the confusion worth deleting.
|
|
19403
|
+
*
|
|
19404
|
+
* Two properties are pinned by spec because both are invisible until they are
|
|
19405
|
+
* wrong:
|
|
19406
|
+
*
|
|
19407
|
+
* - Deriving the day in UTC shifts every band by the browser's offset, and an
|
|
19408
|
+
* operator in Berlin would see a 22:00 clip filed under the next day.
|
|
19409
|
+
* - Walking days by adding 86 400 000 ms repeats or skips a calendar day
|
|
19410
|
+
* across a DST change. The walk goes through `Date`'s own day arithmetic.
|
|
19411
|
+
*
|
|
19412
|
+
* A day id is `YYYY-MM-DD` — it orders lexicographically, which is what lets
|
|
19413
|
+
* the band's day walk terminate on a comparison rather than on a count.
|
|
19414
|
+
*/
|
|
18811
19415
|
function pad2(value) {
|
|
18812
19416
|
return value < 10 ? `0${String(value)}` : String(value);
|
|
18813
19417
|
}
|
|
@@ -18820,16 +19424,6 @@ function startOfDay(dayId) {
|
|
|
18820
19424
|
function toDayId(instant) {
|
|
18821
19425
|
return `${String(instant.getFullYear())}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
|
|
18822
19426
|
}
|
|
18823
|
-
/** The half-open local-wall-clock window of one day. */
|
|
18824
|
-
function dayWindow$1(dayId) {
|
|
18825
|
-
const start = startOfDay(dayId);
|
|
18826
|
-
const end = startOfDay(dayId);
|
|
18827
|
-
end.setDate(end.getDate() + 1);
|
|
18828
|
-
return {
|
|
18829
|
-
since: start.getTime(),
|
|
18830
|
-
until: end.getTime()
|
|
18831
|
-
};
|
|
18832
|
-
}
|
|
18833
19427
|
/** Walk `days` calendar days from `dayId` — DST-safe, because `Date` does it. */
|
|
18834
19428
|
function shiftDay(dayId, days) {
|
|
18835
19429
|
const moved = startOfDay(dayId);
|
|
@@ -18837,11 +19431,12 @@ function shiftDay(dayId, days) {
|
|
|
18837
19431
|
return toDayId(moved);
|
|
18838
19432
|
}
|
|
18839
19433
|
/**
|
|
18840
|
-
* The day, spelled out
|
|
19434
|
+
* The day, spelled out — the calendar half of a band's label.
|
|
18841
19435
|
*
|
|
18842
|
-
* An empty list has to say WHICH
|
|
18843
|
-
* beside it is the ambiguity this surface exists to remove
|
|
18844
|
-
*
|
|
19436
|
+
* An empty list has to say WHICH period it is empty for; "no clips" with no
|
|
19437
|
+
* date beside it is the ambiguity this surface exists to remove. `formatBandLabel`
|
|
19438
|
+
* puts the times around it, and every state renders the result, not only the
|
|
19439
|
+
* populated one.
|
|
18845
19440
|
*/
|
|
18846
19441
|
function formatDayLabel(dayId) {
|
|
18847
19442
|
return startOfDay(dayId).toLocaleDateString(void 0, {
|
|
@@ -18851,6 +19446,77 @@ function formatDayLabel(dayId) {
|
|
|
18851
19446
|
day: "numeric"
|
|
18852
19447
|
});
|
|
18853
19448
|
}
|
|
19449
|
+
/** Defensive ceiling on the day walk so an inverted band can never loop. */
|
|
19450
|
+
var MAX_WALK_DAYS = 366;
|
|
19451
|
+
/**
|
|
19452
|
+
* How many LOCAL calendar days the half-open band touches.
|
|
19453
|
+
*
|
|
19454
|
+
* The end instant is exclusive, so a band that ends exactly at the next local
|
|
19455
|
+
* midnight is one day, not two — otherwise every whole-day band would report
|
|
19456
|
+
* double what it costs.
|
|
19457
|
+
*/
|
|
19458
|
+
function localDaysInBand(band) {
|
|
19459
|
+
const lastInstant = Math.max(band.fromMs, band.toMs - 1);
|
|
19460
|
+
const endDay = toDayId(new Date(lastInstant));
|
|
19461
|
+
let cursor = toDayId(new Date(band.fromMs));
|
|
19462
|
+
let days = 0;
|
|
19463
|
+
while (cursor <= endDay && days < MAX_WALK_DAYS) {
|
|
19464
|
+
days += 1;
|
|
19465
|
+
cursor = shiftDay(cursor, 1);
|
|
19466
|
+
}
|
|
19467
|
+
return days;
|
|
19468
|
+
}
|
|
19469
|
+
/** Whether this band may be asked for, and what it costs, in one value. */
|
|
19470
|
+
function planClipBand(band) {
|
|
19471
|
+
const localDays = localDaysInBand(band);
|
|
19472
|
+
if (localDays > 2) return {
|
|
19473
|
+
kind: "too-wide",
|
|
19474
|
+
band,
|
|
19475
|
+
localDays,
|
|
19476
|
+
maxLocalDays: 2
|
|
19477
|
+
};
|
|
19478
|
+
return {
|
|
19479
|
+
kind: "askable",
|
|
19480
|
+
band,
|
|
19481
|
+
localDays
|
|
19482
|
+
};
|
|
19483
|
+
}
|
|
19484
|
+
/**
|
|
19485
|
+
* The band for the current selection: the picked bucket, or — when nothing is
|
|
19486
|
+
* picked — the whole visible window.
|
|
19487
|
+
*
|
|
19488
|
+
* "Nothing picked means everything visible" is safe here and only here,
|
|
19489
|
+
* because the visible window is itself bounded by the timebar. It is never
|
|
19490
|
+
* "everything the camera holds" (D382's unstated ask).
|
|
19491
|
+
*/
|
|
19492
|
+
function bandForSelection(view, bucket) {
|
|
19493
|
+
if (bucket === null) return {
|
|
19494
|
+
fromMs: view.fromMs,
|
|
19495
|
+
toMs: view.toMs
|
|
19496
|
+
};
|
|
19497
|
+
return {
|
|
19498
|
+
fromMs: Math.max(bucket.startMs, view.fromMs),
|
|
19499
|
+
toMs: Math.min(bucket.endMs, view.toMs)
|
|
19500
|
+
};
|
|
19501
|
+
}
|
|
19502
|
+
function formatTime$2(instant) {
|
|
19503
|
+
return new Date(instant).toLocaleTimeString(void 0, {
|
|
19504
|
+
hour: "2-digit",
|
|
19505
|
+
minute: "2-digit"
|
|
19506
|
+
});
|
|
19507
|
+
}
|
|
19508
|
+
/**
|
|
19509
|
+
* The band, spelled out.
|
|
19510
|
+
*
|
|
19511
|
+
* Every state renders it — an empty list has to say WHICH period it is empty
|
|
19512
|
+
* for, the same rule the day label carried before the band replaced it.
|
|
19513
|
+
*/
|
|
19514
|
+
function formatBandLabel(band) {
|
|
19515
|
+
const fromDay = toDayId(new Date(band.fromMs));
|
|
19516
|
+
const toDay = toDayId(new Date(Math.max(band.fromMs, band.toMs - 1)));
|
|
19517
|
+
if (fromDay === toDay) return `${formatDayLabel(fromDay)}, ${formatTime$2(band.fromMs)} – ${formatTime$2(band.toMs)}`;
|
|
19518
|
+
return `${formatDayLabel(fromDay)} ${formatTime$2(band.fromMs)} – ${formatDayLabel(toDay)} ${formatTime$2(band.toMs)}`;
|
|
19519
|
+
}
|
|
18854
19520
|
//#endregion
|
|
18855
19521
|
//#region src/composites/clips/clip-source-state.ts
|
|
18856
19522
|
var STATE_COPY = {
|
|
@@ -18913,6 +19579,12 @@ function formatCatalogAge(catalogAsOf, now) {
|
|
|
18913
19579
|
//#endregion
|
|
18914
19580
|
//#region src/composites/clips/clip-view-state.ts
|
|
18915
19581
|
function resolveClipsView(input) {
|
|
19582
|
+
if (input.plan.kind === "too-wide") return {
|
|
19583
|
+
kind: "refused",
|
|
19584
|
+
band: input.plan.band,
|
|
19585
|
+
localDays: input.plan.localDays,
|
|
19586
|
+
maxLocalDays: input.plan.maxLocalDays
|
|
19587
|
+
};
|
|
18916
19588
|
if (input.clipsStatus === "error") return {
|
|
18917
19589
|
kind: "failed",
|
|
18918
19590
|
message: input.clipsError ?? "The clip list could not be read"
|
|
@@ -18928,7 +19600,7 @@ function resolveClipsView(input) {
|
|
|
18928
19600
|
const clips = input.clips;
|
|
18929
19601
|
if (clips.length === 0) return {
|
|
18930
19602
|
kind: "empty",
|
|
18931
|
-
|
|
19603
|
+
band: input.plan.band,
|
|
18932
19604
|
answered,
|
|
18933
19605
|
silent,
|
|
18934
19606
|
nobodyAnswered: answered.length === 0
|
|
@@ -18979,6 +19651,28 @@ function resolveClipsView(input) {
|
|
|
18979
19651
|
* camera whose own index disagrees with its own calendar (618, 6–20
|
|
18980
19652
|
* September) is not quiet either.
|
|
18981
19653
|
*
|
|
19654
|
+
* ## The period comes from the recording section's timebar, not a day picker
|
|
19655
|
+
*
|
|
19656
|
+
* It shipped with its own `‹ day ›` pair and the operator replaced it the same
|
|
19657
|
+
* way he replaced the chrome: *"Possiamo utilizzare lo stesso componente della
|
|
19658
|
+
* sezione recording con la timebar dove selezionare un periodo e mostrare le
|
|
19659
|
+
* clip solo di quella banda selezionata, espandibile."* So the list follows a
|
|
19660
|
+
* BAND (D564) — `TimelineControls` for the day, the zoom and the pan,
|
|
19661
|
+
* `EventHeatmap` for picking a narrower window out of it, both of them the
|
|
19662
|
+
* pieces `RecordingTimeline` is itself built from, over the same
|
|
19663
|
+
* `recording-timeline-model`. Three things that are decisions, not details:
|
|
19664
|
+
*
|
|
19665
|
+
* - **The band lives HERE, once.** `ClipBandBar` is presentational. Two
|
|
19666
|
+
* components holding two ideas of the selected period is the defect this
|
|
19667
|
+
* repo keeps paying for, and the period is what the query is made of.
|
|
19668
|
+
* - **`CoverageTrack` stayed behind**, because there it is the seek surface
|
|
19669
|
+
* that moves the hero player's playhead. A copy here would be a dead bar or
|
|
19670
|
+
* a second claimant on that position.
|
|
19671
|
+
* - **A band is asked for only if it is affordable.** `clip-band.ts` counts
|
|
19672
|
+
* the LOCAL days it touches — the camera's own search is clamped to one per
|
|
19673
|
+
* call, and a day listing is 4.3–6.0 s on 592 — and refuses past the bound
|
|
19674
|
+
* by name rather than asking and rendering whichever days answered.
|
|
19675
|
+
*
|
|
18982
19676
|
* ## The picker WRITES nothing, and there is nothing for it to write
|
|
18983
19677
|
*
|
|
18984
19678
|
* A clip source is not enabled or disabled — it EXISTS, because of a
|
|
@@ -19098,24 +19792,44 @@ function SilentSources({ silent }) {
|
|
|
19098
19792
|
});
|
|
19099
19793
|
}
|
|
19100
19794
|
function ClipsSection({ deviceId }) {
|
|
19101
|
-
const [
|
|
19795
|
+
const [day, setDay] = (0, react$1.useState)(() => /* @__PURE__ */ new Date());
|
|
19796
|
+
const [view, setView] = (0, react$1.useState)(() => dayWindow(/* @__PURE__ */ new Date(), Date.now()));
|
|
19797
|
+
const [selectedBucket, setSelectedBucket] = (0, react$1.useState)(null);
|
|
19102
19798
|
const [provider, setProvider] = (0, react$1.useState)("");
|
|
19103
19799
|
const [page, setPage] = (0, react$1.useState)(1);
|
|
19104
19800
|
const [selectedClip, setSelectedClip] = (0, react$1.useState)(null);
|
|
19105
19801
|
const [profile, setProfile] = (0, react$1.useState)("low");
|
|
19106
19802
|
const presence = useCapabilityPresence("videoclips");
|
|
19107
|
-
const
|
|
19803
|
+
const dayBounds = (0, react$1.useMemo)(() => dayWindow(day, Date.now()), [day]);
|
|
19804
|
+
const bucketMs = bucketMsForSpan(view.toMs - view.fromMs);
|
|
19805
|
+
const density = usePipelineAnalyticsGetEventDensity({
|
|
19806
|
+
deviceId,
|
|
19807
|
+
since: view.fromMs,
|
|
19808
|
+
until: view.toMs,
|
|
19809
|
+
bucketMs
|
|
19810
|
+
});
|
|
19811
|
+
const buckets = (0, react$1.useMemo)(() => densityRowsToBuckets(density.data ?? [], view.fromMs, bucketMs), [
|
|
19812
|
+
density.data,
|
|
19813
|
+
view.fromMs,
|
|
19814
|
+
bucketMs
|
|
19815
|
+
]);
|
|
19816
|
+
const selected = selectedBucket === null ? void 0 : buckets.find((b) => b.index === selectedBucket);
|
|
19817
|
+
const band = (0, react$1.useMemo)(() => bandForSelection(view, selected === void 0 ? null : {
|
|
19818
|
+
startMs: selected.startMs,
|
|
19819
|
+
endMs: selected.endMs
|
|
19820
|
+
}), [view, selected]);
|
|
19821
|
+
const plan = planClipBand(band);
|
|
19108
19822
|
const sources = useVideoclipsListSources({ deviceId });
|
|
19109
19823
|
const clips = useVideoclipsListClips({
|
|
19110
19824
|
deviceId,
|
|
19111
|
-
since:
|
|
19112
|
-
until:
|
|
19825
|
+
since: band.fromMs,
|
|
19826
|
+
until: band.toMs,
|
|
19113
19827
|
limit: CLIP_PAGE_LIMIT,
|
|
19114
19828
|
...provider === "" ? {} : { provider }
|
|
19115
|
-
});
|
|
19829
|
+
}, { enabled: plan.kind === "askable" });
|
|
19116
19830
|
const sourceRows = sources.data ?? [];
|
|
19117
|
-
const
|
|
19118
|
-
|
|
19831
|
+
const clipsView = resolveClipsView({
|
|
19832
|
+
plan,
|
|
19119
19833
|
clipsStatus: clips.status,
|
|
19120
19834
|
clips: clips.data ?? [],
|
|
19121
19835
|
sourcesStatus: sources.status,
|
|
@@ -19147,9 +19861,20 @@ function ClipsSection({ deviceId }) {
|
|
|
19147
19861
|
notice: describeSource(row),
|
|
19148
19862
|
catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
|
|
19149
19863
|
})), [sourceRows]);
|
|
19150
|
-
|
|
19151
|
-
setDayId(next);
|
|
19864
|
+
(0, react$1.useEffect)(() => {
|
|
19152
19865
|
setPage(1);
|
|
19866
|
+
}, [band.fromMs, band.toMs]);
|
|
19867
|
+
const handleDayChange = (next) => {
|
|
19868
|
+
setDay(next);
|
|
19869
|
+
setView(dayWindow(next, Date.now()));
|
|
19870
|
+
setSelectedBucket(null);
|
|
19871
|
+
};
|
|
19872
|
+
const handleViewChange = (next) => {
|
|
19873
|
+
setView(next);
|
|
19874
|
+
setSelectedBucket(null);
|
|
19875
|
+
};
|
|
19876
|
+
const handleSelectBucket = (index) => {
|
|
19877
|
+
setSelectedBucket((current) => current === index ? null : index);
|
|
19153
19878
|
};
|
|
19154
19879
|
const pick = (next) => {
|
|
19155
19880
|
setProvider(next);
|
|
@@ -19176,18 +19901,25 @@ function ClipsSection({ deviceId }) {
|
|
|
19176
19901
|
description: "Nothing is installed that could list a camera's stored clips."
|
|
19177
19902
|
})
|
|
19178
19903
|
});
|
|
19179
|
-
const visible =
|
|
19904
|
+
const visible = clipsView.kind === "listed" ? clipsView.clips : [];
|
|
19180
19905
|
const pageClips = visible.slice((page - 1) * TILES_PER_PAGE, page * TILES_PER_PAGE);
|
|
19181
19906
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
19182
19907
|
className: "space-y-3",
|
|
19183
19908
|
children: [
|
|
19184
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
|
|
19185
|
-
|
|
19186
|
-
|
|
19187
|
-
|
|
19188
|
-
|
|
19189
|
-
|
|
19190
|
-
|
|
19909
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
|
|
19910
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
19911
|
+
className: "text-sm font-semibold text-foreground",
|
|
19912
|
+
children: "Clips"
|
|
19913
|
+
}),
|
|
19914
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
19915
|
+
className: "mt-1 text-[11px] text-foreground-subtle",
|
|
19916
|
+
children: "Every source this camera has, side by side — CamStack's own recordings and whatever the camera or an export keeps of its own. A source is listed because it exists, not because it was switched on here; pick sources to narrow the view."
|
|
19917
|
+
}),
|
|
19918
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
19919
|
+
className: "mt-1 text-[11px] text-foreground-subtle",
|
|
19920
|
+
children: "The list follows the period selected on the timebar below — the recording section's own control. Zoom out to widen it, pan or pick a day to move it, click a density bar to narrow it to that window."
|
|
19921
|
+
})
|
|
19922
|
+
] }),
|
|
19191
19923
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
19192
19924
|
className: "flex flex-wrap items-center gap-2",
|
|
19193
19925
|
children: [
|
|
@@ -19212,32 +19944,28 @@ function ClipsSection({ deviceId }) {
|
|
|
19212
19944
|
}) : null,
|
|
19213
19945
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
19214
19946
|
className: "ml-auto flex items-center gap-1",
|
|
19215
|
-
children: [
|
|
19216
|
-
|
|
19217
|
-
|
|
19218
|
-
|
|
19219
|
-
|
|
19220
|
-
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
className: "px-1 text-xs font-medium text-foreground",
|
|
19224
|
-
children: formatDayLabel(dayId)
|
|
19225
|
-
}),
|
|
19226
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
19227
|
-
variant: "ghost",
|
|
19228
|
-
"aria-label": "Next day",
|
|
19229
|
-
onClick: () => goToDay(shiftDay(dayId, 1)),
|
|
19230
|
-
children: "›"
|
|
19231
|
-
}),
|
|
19232
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
19233
|
-
variant: "outline",
|
|
19234
|
-
onClick: () => goToDay(toDayId(/* @__PURE__ */ new Date())),
|
|
19235
|
-
children: "Today"
|
|
19236
|
-
})
|
|
19237
|
-
]
|
|
19947
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
19948
|
+
className: "px-1 text-xs font-medium text-foreground",
|
|
19949
|
+
children: formatBandLabel(band)
|
|
19950
|
+
}), selectedBucket === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
|
|
19951
|
+
variant: "outline",
|
|
19952
|
+
onClick: () => setSelectedBucket(null),
|
|
19953
|
+
children: "Expand to the visible window"
|
|
19954
|
+
})]
|
|
19238
19955
|
})
|
|
19239
19956
|
]
|
|
19240
19957
|
}),
|
|
19958
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipBandBar, {
|
|
19959
|
+
day,
|
|
19960
|
+
onDayChange: handleDayChange,
|
|
19961
|
+
view,
|
|
19962
|
+
dayBounds,
|
|
19963
|
+
onViewChange: handleViewChange,
|
|
19964
|
+
buckets,
|
|
19965
|
+
selectedBucketIndex: selectedBucket,
|
|
19966
|
+
onSelectBucket: handleSelectBucket,
|
|
19967
|
+
densityLoading: density.status === "pending"
|
|
19968
|
+
}),
|
|
19241
19969
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
19242
19970
|
"aria-label": "Clip sources",
|
|
19243
19971
|
className: "space-y-1",
|
|
@@ -19254,26 +19982,30 @@ function ClipsSection({ deviceId }) {
|
|
|
19254
19982
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
19255
19983
|
"aria-label": "Clips",
|
|
19256
19984
|
className: "space-y-2",
|
|
19257
|
-
children:
|
|
19985
|
+
children: clipsView.kind === "refused" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
|
|
19986
|
+
variant: "warning",
|
|
19987
|
+
title: "That period is too wide to list",
|
|
19988
|
+
message: `${formatBandLabel(clipsView.band)} covers ${String(clipsView.localDays)} local days, and a clip listing is asked one local day at a time — at most ${String(clipsView.maxLocalDays)}. Nothing was asked for; narrow the band and the list follows it.`
|
|
19989
|
+
}) : clipsView.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
19258
19990
|
role: "status",
|
|
19259
19991
|
className: "text-xs text-foreground-subtle",
|
|
19260
19992
|
children: [
|
|
19261
19993
|
"Loading clips for ",
|
|
19262
|
-
|
|
19994
|
+
formatBandLabel(band),
|
|
19263
19995
|
"…"
|
|
19264
19996
|
]
|
|
19265
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) :
|
|
19997
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) : clipsView.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
|
|
19266
19998
|
title: "The clip list could not be read",
|
|
19267
|
-
message:
|
|
19268
|
-
}) :
|
|
19999
|
+
message: clipsView.message
|
|
20000
|
+
}) : clipsView.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
19269
20001
|
role: "status",
|
|
19270
20002
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
|
|
19271
20003
|
icon: Film,
|
|
19272
|
-
title:
|
|
19273
|
-
...
|
|
20004
|
+
title: clipsView.nobodyAnswered ? `No source could answer for ${formatBandLabel(clipsView.band)}` : `No clips in ${formatBandLabel(clipsView.band)}`,
|
|
20005
|
+
...clipsView.nobodyAnswered ? {} : { description: `Asked ${clipsView.answered.map((notice) => notice.label).join(", ")}.` }
|
|
19274
20006
|
})
|
|
19275
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent:
|
|
19276
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent:
|
|
20007
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
20008
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent }),
|
|
19277
20009
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
19278
20010
|
className: "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
|
|
19279
20011
|
children: pageClips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
|
|
@@ -21469,201 +22201,6 @@ function formatBytes(bytes) {
|
|
|
21469
22201
|
return `${value >= 100 ? Math.round(value).toString() : value.toFixed(1)} ${unit}`;
|
|
21470
22202
|
}
|
|
21471
22203
|
//#endregion
|
|
21472
|
-
//#region src/composites/cap-settings/recording-timeline-model.ts
|
|
21473
|
-
/** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
|
|
21474
|
-
var KIND_PRECEDENCE = [
|
|
21475
|
-
"object",
|
|
21476
|
-
"motion",
|
|
21477
|
-
"audio"
|
|
21478
|
-
];
|
|
21479
|
-
/**
|
|
21480
|
-
* Convert server-side density rows into `DensityBucket[]` for the timeline
|
|
21481
|
-
* heatmap. Rows with total === 0 are skipped (defensive; server already omits
|
|
21482
|
-
* them). Returns a sparse, ascending-by-index array — EventHeatmap positions
|
|
21483
|
-
* each bar by `b.startMs` rather than array index, so gaps are fine.
|
|
21484
|
-
*/
|
|
21485
|
-
function densityRowsToBuckets(rows, windowFrom, bucketMs) {
|
|
21486
|
-
const buckets = [];
|
|
21487
|
-
for (const row of rows) {
|
|
21488
|
-
const counts = {
|
|
21489
|
-
motion: row.motion,
|
|
21490
|
-
object: row.object,
|
|
21491
|
-
audio: row.audio
|
|
21492
|
-
};
|
|
21493
|
-
const total = counts.motion + counts.object + counts.audio;
|
|
21494
|
-
if (total === 0) continue;
|
|
21495
|
-
const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
|
|
21496
|
-
let dominant = null;
|
|
21497
|
-
let best = -1;
|
|
21498
|
-
for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
|
|
21499
|
-
best = counts[kind];
|
|
21500
|
-
dominant = kind;
|
|
21501
|
-
}
|
|
21502
|
-
buckets.push({
|
|
21503
|
-
index,
|
|
21504
|
-
startMs: row.bucketStart,
|
|
21505
|
-
endMs: row.bucketStart + bucketMs,
|
|
21506
|
-
counts,
|
|
21507
|
-
total,
|
|
21508
|
-
dominant
|
|
21509
|
-
});
|
|
21510
|
-
}
|
|
21511
|
-
return buckets.toSorted((a, b) => a.index - b.index);
|
|
21512
|
-
}
|
|
21513
|
-
/** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
|
|
21514
|
-
function distanceToSpan(ms, span) {
|
|
21515
|
-
if (ms < span.startMs) return span.startMs - ms;
|
|
21516
|
-
if (ms > span.endMs) return ms - span.endMs;
|
|
21517
|
-
return 0;
|
|
21518
|
-
}
|
|
21519
|
-
/**
|
|
21520
|
-
* Resolve a playback window for a click at `clickMs` against recorded `spans`.
|
|
21521
|
-
* Inside a span → play from the click to that span's end. In a gap / outside →
|
|
21522
|
-
* snap to the WHOLE nearest span. No spans → null (nothing to play).
|
|
21523
|
-
* `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
|
|
21524
|
-
*/
|
|
21525
|
-
function seekTargetForSpans(clickMs, spans) {
|
|
21526
|
-
if (spans.length === 0) return null;
|
|
21527
|
-
const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
|
|
21528
|
-
if (containing) return {
|
|
21529
|
-
fromMs: clickMs,
|
|
21530
|
-
toMs: containing.endMs
|
|
21531
|
-
};
|
|
21532
|
-
let nearest = null;
|
|
21533
|
-
let bestDist = Infinity;
|
|
21534
|
-
for (const s of spans) {
|
|
21535
|
-
const d = distanceToSpan(clickMs, s);
|
|
21536
|
-
if (d < bestDist) {
|
|
21537
|
-
bestDist = d;
|
|
21538
|
-
nearest = s;
|
|
21539
|
-
}
|
|
21540
|
-
}
|
|
21541
|
-
return nearest ? {
|
|
21542
|
-
fromMs: nearest.startMs,
|
|
21543
|
-
toMs: nearest.endMs
|
|
21544
|
-
} : null;
|
|
21545
|
-
}
|
|
21546
|
-
/**
|
|
21547
|
-
* Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
|
|
21548
|
-
* (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
|
|
21549
|
-
*/
|
|
21550
|
-
function pctForMs(ms, fromMs, toMs) {
|
|
21551
|
-
const span = toMs - fromMs;
|
|
21552
|
-
if (span <= 0) return 0;
|
|
21553
|
-
const pct = (ms - fromMs) / span * 100;
|
|
21554
|
-
if (pct < 0) return 0;
|
|
21555
|
-
if (pct > 100) return 100;
|
|
21556
|
-
return pct;
|
|
21557
|
-
}
|
|
21558
|
-
/**
|
|
21559
|
-
* Build a DayWindow for the given calendar `date` using local time.
|
|
21560
|
-
* For any day strictly in the past: `fromMs` = local midnight, `toMs` =
|
|
21561
|
-
* next local midnight (exactly 24 h).
|
|
21562
|
-
* For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
|
|
21563
|
-
* window never extends into the future.
|
|
21564
|
-
*/
|
|
21565
|
-
function dayWindow(date, nowMs) {
|
|
21566
|
-
const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
|
|
21567
|
-
const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
|
|
21568
|
-
return {
|
|
21569
|
-
fromMs,
|
|
21570
|
-
toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
|
|
21571
|
-
};
|
|
21572
|
-
}
|
|
21573
|
-
/** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
|
|
21574
|
-
var ZOOM_LEVELS = [
|
|
21575
|
-
24 * 36e5,
|
|
21576
|
-
12 * 36e5,
|
|
21577
|
-
6 * 36e5,
|
|
21578
|
-
36e5,
|
|
21579
|
-
15 * 6e4
|
|
21580
|
-
];
|
|
21581
|
-
/**
|
|
21582
|
-
* Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
|
|
21583
|
-
* `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
|
|
21584
|
-
* midpoint. On today's timeline the view's right edge is "now", so zooming this
|
|
21585
|
-
* way keeps the most recent footage pinned to the right instead of drifting to
|
|
21586
|
-
* a midday range that no longer contains now. Panning then moves back/forward.
|
|
21587
|
-
*
|
|
21588
|
-
* Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
|
|
21589
|
-
* show a wider window than the day holds — e.g. early morning with little
|
|
21590
|
-
* elapsed time).
|
|
21591
|
-
*/
|
|
21592
|
-
function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
|
|
21593
|
-
if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
|
|
21594
|
-
const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
|
|
21595
|
-
return {
|
|
21596
|
-
fromMs: toMs - levelMs,
|
|
21597
|
-
toMs
|
|
21598
|
-
};
|
|
21599
|
-
}
|
|
21600
|
-
/**
|
|
21601
|
-
* Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
|
|
21602
|
-
*/
|
|
21603
|
-
function panWindow(win, dayBounds, deltaMs) {
|
|
21604
|
-
const width = win.toMs - win.fromMs;
|
|
21605
|
-
let fromMs = win.fromMs + deltaMs;
|
|
21606
|
-
let toMs = win.toMs + deltaMs;
|
|
21607
|
-
if (fromMs < dayBounds.fromMs) {
|
|
21608
|
-
fromMs = dayBounds.fromMs;
|
|
21609
|
-
toMs = fromMs + width;
|
|
21610
|
-
}
|
|
21611
|
-
if (toMs > dayBounds.toMs) {
|
|
21612
|
-
toMs = dayBounds.toMs;
|
|
21613
|
-
fromMs = toMs - width;
|
|
21614
|
-
}
|
|
21615
|
-
return {
|
|
21616
|
-
fromMs,
|
|
21617
|
-
toMs
|
|
21618
|
-
};
|
|
21619
|
-
}
|
|
21620
|
-
/** Candidate bucket sizes in ms (coarsest first). */
|
|
21621
|
-
var BUCKET_CANDIDATES = [
|
|
21622
|
-
15 * 6e4,
|
|
21623
|
-
5 * 6e4,
|
|
21624
|
-
6e4,
|
|
21625
|
-
3e4,
|
|
21626
|
-
15e3,
|
|
21627
|
-
5e3
|
|
21628
|
-
];
|
|
21629
|
-
/**
|
|
21630
|
-
* Choose a sensible heatmap bucket size for a visible span of `spanMs`,
|
|
21631
|
-
* targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
|
|
21632
|
-
*/
|
|
21633
|
-
function bucketMsForSpan(spanMs) {
|
|
21634
|
-
const targetBucket = spanMs / 100;
|
|
21635
|
-
for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
|
|
21636
|
-
return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
|
|
21637
|
-
}
|
|
21638
|
-
/**
|
|
21639
|
-
* Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
|
|
21640
|
-
* - `pct` is via pctForMs (first=0, last=100).
|
|
21641
|
-
* - `label` is a localised HH:MM clock string.
|
|
21642
|
-
*/
|
|
21643
|
-
function axisTicks(fromMs, toMs, count) {
|
|
21644
|
-
if (count <= 1) return [{
|
|
21645
|
-
pct: 0,
|
|
21646
|
-
label: new Date(fromMs).toLocaleTimeString(void 0, {
|
|
21647
|
-
hour: "2-digit",
|
|
21648
|
-
minute: "2-digit"
|
|
21649
|
-
})
|
|
21650
|
-
}];
|
|
21651
|
-
const ticks = [];
|
|
21652
|
-
for (let i = 0; i < count; i++) {
|
|
21653
|
-
const ms = fromMs + i / (count - 1) * (toMs - fromMs);
|
|
21654
|
-
const pct = pctForMs(ms, fromMs, toMs);
|
|
21655
|
-
const label = new Date(ms).toLocaleTimeString(void 0, {
|
|
21656
|
-
hour: "2-digit",
|
|
21657
|
-
minute: "2-digit"
|
|
21658
|
-
});
|
|
21659
|
-
ticks.push({
|
|
21660
|
-
pct,
|
|
21661
|
-
label
|
|
21662
|
-
});
|
|
21663
|
-
}
|
|
21664
|
-
return ticks;
|
|
21665
|
-
}
|
|
21666
|
-
//#endregion
|
|
21667
22204
|
//#region src/composites/cap-settings/CoverageTrack.tsx
|
|
21668
22205
|
function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScrubStart, onScrubMove, onScrubEnd }) {
|
|
21669
22206
|
const [hoverInfo, setHoverInfo] = (0, react$1.useState)(null);
|
|
@@ -21774,42 +22311,6 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScru
|
|
|
21774
22311
|
});
|
|
21775
22312
|
}
|
|
21776
22313
|
//#endregion
|
|
21777
|
-
//#region src/composites/cap-settings/EventHeatmap.tsx
|
|
21778
|
-
var KIND_BG = {
|
|
21779
|
-
motion: "bg-emerald-500",
|
|
21780
|
-
object: "bg-violet-500",
|
|
21781
|
-
audio: "bg-orange-500"
|
|
21782
|
-
};
|
|
21783
|
-
function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
|
|
21784
|
-
const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
|
|
21785
|
-
const firstBucket = buckets[0];
|
|
21786
|
-
const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
|
|
21787
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
21788
|
-
className: "relative h-6 rounded bg-background/40",
|
|
21789
|
-
children: buckets.map((b) => {
|
|
21790
|
-
if (b.total === 0 || b.dominant === null) return null;
|
|
21791
|
-
const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
|
|
21792
|
-
const left = pctForMs(b.startMs, windowFrom, windowTo);
|
|
21793
|
-
const selected = b.index === selectedIndex;
|
|
21794
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
21795
|
-
type: "button",
|
|
21796
|
-
"data-testid": "heatmap-bar",
|
|
21797
|
-
title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
|
|
21798
|
-
hour: "2-digit",
|
|
21799
|
-
minute: "2-digit"
|
|
21800
|
-
})} · motion ${b.counts.motion} · object ${b.counts.object}`,
|
|
21801
|
-
onClick: () => onSelectBucket(b.index),
|
|
21802
|
-
className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
|
|
21803
|
-
style: {
|
|
21804
|
-
left: `${left}%`,
|
|
21805
|
-
width: `${Math.max(.6, bucketWidthPct)}%`,
|
|
21806
|
-
height: `${heightPct}%`
|
|
21807
|
-
}
|
|
21808
|
-
}, b.index);
|
|
21809
|
-
})
|
|
21810
|
-
});
|
|
21811
|
-
}
|
|
21812
|
-
//#endregion
|
|
21813
22314
|
//#region src/composites/cap-settings/event-thumb.tsx
|
|
21814
22315
|
function EventThumb({ mediaUrl, kind }) {
|
|
21815
22316
|
const [failed, setFailed] = (0, react$1.useState)(false);
|
|
@@ -21934,142 +22435,6 @@ function EventBucketStrip({ clips, loading, busyKey, onPlay, enabledKinds, onTog
|
|
|
21934
22435
|
});
|
|
21935
22436
|
}
|
|
21936
22437
|
//#endregion
|
|
21937
|
-
//#region src/composites/cap-settings/TimelineControls.tsx
|
|
21938
|
-
/** Format a Date as "YYYY-MM-DD" for <input type="date">. */
|
|
21939
|
-
function formatDateInput(date) {
|
|
21940
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
21941
|
-
}
|
|
21942
|
-
/** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
|
|
21943
|
-
function parseDateInput(value) {
|
|
21944
|
-
const parts = value.split("-");
|
|
21945
|
-
const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
|
|
21946
|
-
const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
|
|
21947
|
-
const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
|
|
21948
|
-
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
|
|
21949
|
-
return new Date(y, m - 1, d);
|
|
21950
|
-
}
|
|
21951
|
-
/**
|
|
21952
|
-
* Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
|
|
21953
|
-
* timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
|
|
21954
|
-
* the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
|
|
21955
|
-
* instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
|
|
21956
|
-
* window earlier/later within the day (the discoverable equivalent of the
|
|
21957
|
-
* horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
|
|
21958
|
-
* navigated back and forth without a mouse wheel.
|
|
21959
|
-
*/
|
|
21960
|
-
function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
|
|
21961
|
-
const handleDateChange = (e) => {
|
|
21962
|
-
const parsed = parseDateInput(e.target.value);
|
|
21963
|
-
if (parsed !== null) onDayChange(parsed);
|
|
21964
|
-
};
|
|
21965
|
-
const viewSpan = view.toMs - view.fromMs;
|
|
21966
|
-
const currentLevelIdx = (() => {
|
|
21967
|
-
let best = 0;
|
|
21968
|
-
let bestDiff = Infinity;
|
|
21969
|
-
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
|
21970
|
-
const level = ZOOM_LEVELS[i];
|
|
21971
|
-
if (level === void 0) continue;
|
|
21972
|
-
const diff = Math.abs(level - viewSpan);
|
|
21973
|
-
if (diff < bestDiff) {
|
|
21974
|
-
bestDiff = diff;
|
|
21975
|
-
best = i;
|
|
21976
|
-
}
|
|
21977
|
-
}
|
|
21978
|
-
return best;
|
|
21979
|
-
})();
|
|
21980
|
-
const rightEdgeMs = view.toMs;
|
|
21981
|
-
const handleZoomIn = () => {
|
|
21982
|
-
const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
|
|
21983
|
-
if (nextLevel === void 0) return;
|
|
21984
|
-
onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
|
|
21985
|
-
};
|
|
21986
|
-
const handleZoomOut = () => {
|
|
21987
|
-
const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
|
|
21988
|
-
if (nextLevel === void 0) return;
|
|
21989
|
-
onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
|
|
21990
|
-
};
|
|
21991
|
-
const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
|
|
21992
|
-
const canZoomOut = currentLevelIdx > 0;
|
|
21993
|
-
const panStepMs = Math.max(1, Math.round(viewSpan * .9));
|
|
21994
|
-
const canPanPrev = view.fromMs > dayBounds.fromMs;
|
|
21995
|
-
const canPanNext = view.toMs < dayBounds.toMs;
|
|
21996
|
-
const handlePanPrev = () => {
|
|
21997
|
-
onViewChange(panWindow(view, dayBounds, -panStepMs));
|
|
21998
|
-
};
|
|
21999
|
-
const handlePanNext = () => {
|
|
22000
|
-
onViewChange(panWindow(view, dayBounds, panStepMs));
|
|
22001
|
-
};
|
|
22002
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
22003
|
-
className: "flex items-center gap-2",
|
|
22004
|
-
children: [
|
|
22005
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
22006
|
-
type: "date",
|
|
22007
|
-
value: formatDateInput(day),
|
|
22008
|
-
max: formatDateInput(/* @__PURE__ */ new Date()),
|
|
22009
|
-
onChange: handleDateChange,
|
|
22010
|
-
className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
|
|
22011
|
-
"aria-label": "Select recording day"
|
|
22012
|
-
}),
|
|
22013
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
22014
|
-
className: "ml-auto flex items-center gap-1",
|
|
22015
|
-
children: [
|
|
22016
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
22017
|
-
type: "button",
|
|
22018
|
-
onClick: handlePanPrev,
|
|
22019
|
-
disabled: !canPanPrev,
|
|
22020
|
-
title: "Earlier (pan back)",
|
|
22021
|
-
"aria-label": "Pan earlier",
|
|
22022
|
-
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
22023
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
|
|
22024
|
-
}),
|
|
22025
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
22026
|
-
type: "button",
|
|
22027
|
-
onClick: handlePanNext,
|
|
22028
|
-
disabled: !canPanNext,
|
|
22029
|
-
title: "Later (pan forward)",
|
|
22030
|
-
"aria-label": "Pan later",
|
|
22031
|
-
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
22032
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
|
|
22033
|
-
}),
|
|
22034
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
22035
|
-
className: "mx-0.5 h-3 w-px bg-border",
|
|
22036
|
-
"aria-hidden": "true"
|
|
22037
|
-
}),
|
|
22038
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
22039
|
-
type: "button",
|
|
22040
|
-
onClick: handleZoomOut,
|
|
22041
|
-
disabled: !canZoomOut,
|
|
22042
|
-
title: "Zoom out",
|
|
22043
|
-
"aria-label": "Zoom out",
|
|
22044
|
-
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
22045
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
|
|
22046
|
-
}),
|
|
22047
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
22048
|
-
type: "button",
|
|
22049
|
-
onClick: handleZoomIn,
|
|
22050
|
-
disabled: !canZoomIn,
|
|
22051
|
-
title: "Zoom in",
|
|
22052
|
-
"aria-label": "Zoom in",
|
|
22053
|
-
className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
|
|
22054
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
|
|
22055
|
-
})
|
|
22056
|
-
]
|
|
22057
|
-
}),
|
|
22058
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
22059
|
-
className: "text-[9px] text-foreground-subtle",
|
|
22060
|
-
children: zoomLabel(viewSpan)
|
|
22061
|
-
})
|
|
22062
|
-
]
|
|
22063
|
-
});
|
|
22064
|
-
}
|
|
22065
|
-
function zoomLabel(spanMs) {
|
|
22066
|
-
const h = spanMs / 36e5;
|
|
22067
|
-
if (h >= 24) return "24h";
|
|
22068
|
-
if (h >= 1) return `${Math.round(h)}h`;
|
|
22069
|
-
const m = spanMs / 6e4;
|
|
22070
|
-
return `${Math.round(m)}m`;
|
|
22071
|
-
}
|
|
22072
|
-
//#endregion
|
|
22073
22438
|
//#region src/composites/cap-settings/RecordingTimeline.tsx
|
|
22074
22439
|
var ALL_KINDS = [
|
|
22075
22440
|
"motion",
|
|
@@ -22465,15 +22830,6 @@ function RecordingTimeline({ deviceId }) {
|
|
|
22465
22830
|
//#endregion
|
|
22466
22831
|
//#region src/composites/cap-settings/RecordingPanel.tsx
|
|
22467
22832
|
var DAY_MS$1 = 1440 * 60 * 1e3;
|
|
22468
|
-
function formatDuration$1(ms) {
|
|
22469
|
-
const totalSec = Math.round(ms / 1e3);
|
|
22470
|
-
const h = Math.floor(totalSec / 3600);
|
|
22471
|
-
const m = Math.floor(totalSec % 3600 / 60);
|
|
22472
|
-
const s = totalSec % 60;
|
|
22473
|
-
if (h > 0) return `${h}h ${m}m`;
|
|
22474
|
-
if (m > 0) return `${m}m ${s}s`;
|
|
22475
|
-
return `${s}s`;
|
|
22476
|
-
}
|
|
22477
22833
|
function RecordingPanel({ deviceId }) {
|
|
22478
22834
|
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
22479
22835
|
const statusQuery = useRecordingGetStatus({ deviceId });
|
|
@@ -22573,7 +22929,7 @@ function RecordingPanel({ deviceId }) {
|
|
|
22573
22929
|
label: "Recorded (24h)",
|
|
22574
22930
|
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Clock, { className: "h-3.5 w-3.5" }),
|
|
22575
22931
|
footer: `${ranges.length} segment range${ranges.length === 1 ? "" : "s"}`,
|
|
22576
|
-
children: availabilityQuery.isLoading ? "…" :
|
|
22932
|
+
children: availabilityQuery.isLoading ? "…" : formatDurationMs(recordedMs)
|
|
22577
22933
|
})
|
|
22578
22934
|
]
|
|
22579
22935
|
}),
|
|
@@ -54450,6 +54806,7 @@ exports.usePipelineAnalyticsListRetrainStaging = usePipelineAnalyticsListRetrain
|
|
|
54450
54806
|
exports.usePipelineAnalyticsListSummaries = usePipelineAnalyticsListSummaries;
|
|
54451
54807
|
exports.usePipelineAnalyticsListTrackMedia = usePipelineAnalyticsListTrackMedia;
|
|
54452
54808
|
exports.usePipelineAnalyticsListTracks = usePipelineAnalyticsListTracks;
|
|
54809
|
+
exports.usePipelineAnalyticsOwnersWithMedia = usePipelineAnalyticsOwnersWithMedia;
|
|
54453
54810
|
exports.usePipelineAnalyticsPauseForStorageMigration = usePipelineAnalyticsPauseForStorageMigration;
|
|
54454
54811
|
exports.usePipelineAnalyticsProposeRetrainAnnotations = usePipelineAnalyticsProposeRetrainAnnotations;
|
|
54455
54812
|
exports.usePipelineAnalyticsPruneEvents = usePipelineAnalyticsPruneEvents;
|