@ai-matrx/capture 0.1.2 → 0.2.1

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/react.cjs CHANGED
@@ -22,21 +22,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var react_exports = {};
23
23
  __export(react_exports, {
24
24
  CameraCapture: () => CameraCapture,
25
+ CameraFeed: () => CameraFeed,
26
+ CaptureFilmstrip: () => CaptureFilmstrip,
25
27
  CaptureSheet: () => CaptureSheet,
26
28
  CountdownOverlay: () => CountdownOverlay,
27
29
  GridOverlay: () => GridOverlay,
28
30
  ImageEditSheet: () => ImageEditSheet,
31
+ MediaViewer: () => MediaViewer,
29
32
  ModeSelector: () => ModeSelector,
30
33
  OptionsGridPanel: () => OptionsGridPanel,
31
34
  ShutterButton: () => ShutterButton,
32
35
  ZoomRow: () => ZoomRow,
36
+ getMediaUrl: () => getMediaUrl,
37
+ invalidateMedia: () => invalidateMedia,
38
+ primeMedia: () => primeMedia,
39
+ useDefaultCaptureEngine: () => useDefaultCaptureEngine,
40
+ useMediaUrl: () => useMediaUrl,
33
41
  useTrackControls: () => useTrackControls
34
42
  });
35
43
  module.exports = __toCommonJS(react_exports);
36
44
 
37
45
  // src/components/CameraCapture.tsx
38
- var import_react2 = require("react");
39
- var import_lucide_react2 = require("lucide-react");
46
+ var import_react5 = require("react");
47
+ var import_lucide_react4 = require("lucide-react");
40
48
 
41
49
  // src/cn.ts
42
50
  var import_tailwind_merge = require("tailwind-merge");
@@ -44,6 +52,20 @@ function cn(...inputs) {
44
52
  return (0, import_tailwind_merge.twMerge)(inputs.filter(Boolean).join(" "));
45
53
  }
46
54
 
55
+ // src/safe-area.ts
56
+ var safeTop = {
57
+ paddingTop: "env(safe-area-inset-top, 0px)"
58
+ };
59
+ var safeBottom = {
60
+ paddingBottom: "env(safe-area-inset-bottom, 0px)"
61
+ };
62
+ var safeMarginTop = {
63
+ marginTop: "env(safe-area-inset-top, 0px)"
64
+ };
65
+ var safeMarginBottom = {
66
+ marginBottom: "env(safe-area-inset-bottom, 0px)"
67
+ };
68
+
47
69
  // src/hooks/useTrackControls.ts
48
70
  var import_react = require("react");
49
71
  var ZOOM_LADDER = [0.5, 1, 2, 4, 8];
@@ -59,20 +81,31 @@ function useTrackControls(stream) {
59
81
  setCaps(null);
60
82
  return;
61
83
  }
84
+ const timers = [];
62
85
  const read = () => {
63
86
  try {
64
- setCaps(track.getCapabilities());
87
+ const next = track.getCapabilities();
88
+ setCaps(next);
65
89
  const settings = track.getSettings();
66
90
  if (typeof settings.zoom === "number") setZoomState(settings.zoom);
67
91
  if (typeof settings.exposureCompensation === "number")
68
92
  setExposureState(settings.exposureCompensation);
93
+ return next.torch === true || next.zoom !== void 0 || next.exposureCompensation !== void 0;
69
94
  } catch {
70
95
  setCaps(null);
96
+ return false;
71
97
  }
72
98
  };
73
- read();
74
- const timer = window.setTimeout(read, 750);
75
- return () => window.clearTimeout(timer);
99
+ if (!read()) {
100
+ for (const delay of [500, 1200, 2500, 5e3]) {
101
+ timers.push(
102
+ window.setTimeout(() => {
103
+ if (read()) timers.forEach((t) => window.clearTimeout(t));
104
+ }, delay)
105
+ );
106
+ }
107
+ }
108
+ return () => timers.forEach((t) => window.clearTimeout(t));
76
109
  }, [track]);
77
110
  const torchSupported = caps?.torch === true;
78
111
  const toggleTorch = (0, import_react.useCallback)(() => {
@@ -319,25 +352,32 @@ function OptionsGridPanel({
319
352
  className: "absolute inset-0 bg-black/70"
320
353
  }
321
354
  ),
322
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "absolute inset-x-3 bottom-3 mb-safe rounded-[2.5rem] bg-[#3a3a3c]/95 px-6 py-8 shadow-2xl", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "grid grid-cols-3 gap-x-4 gap-y-7", children: tiles.map((tile) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "flex flex-col items-center gap-2.5", children: [
323
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
324
- "button",
325
- {
326
- type: "button",
327
- onClick: tile.onPress,
328
- disabled: tile.disabled,
329
- "aria-label": tile.label,
330
- "aria-pressed": tile.active === true,
331
- className: cn(
332
- "flex h-16 w-16 touch-manipulation items-center justify-center rounded-full bg-[#2c2c2e] transition-colors",
333
- tile.active ? "text-[#FFCC00]" : "text-white",
334
- tile.disabled && "opacity-40"
355
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
356
+ "div",
357
+ {
358
+ className: "absolute inset-x-3 bottom-3 rounded-[2.5rem] bg-[#3a3a3c]/95 px-6 py-8 shadow-2xl",
359
+ style: { marginBottom: "env(safe-area-inset-bottom, 0px)" },
360
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "grid grid-cols-3 gap-x-4 gap-y-7", children: tiles.map((tile) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "flex flex-col items-center gap-2.5", children: [
361
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
362
+ "button",
363
+ {
364
+ type: "button",
365
+ onClick: tile.onPress,
366
+ disabled: tile.disabled,
367
+ "aria-label": tile.label,
368
+ "aria-pressed": tile.active === true,
369
+ className: cn(
370
+ "flex h-16 w-16 touch-manipulation items-center justify-center rounded-full bg-[#2c2c2e] transition-colors",
371
+ tile.active ? "text-[#FFCC00]" : "text-white",
372
+ tile.disabled && "opacity-40"
373
+ ),
374
+ children: tile.icon
375
+ }
335
376
  ),
336
- children: tile.icon
337
- }
338
- ),
339
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "text-[13px] font-semibold uppercase tracking-[0.14em] text-white", children: tile.valueLabel ? `${tile.label} ${tile.valueLabel}` : tile.label })
340
- ] }, tile.id)) }) })
377
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "text-[13px] font-semibold uppercase tracking-[0.14em] text-white", children: tile.valueLabel ? `${tile.label} ${tile.valueLabel}` : tile.label })
378
+ ] }, tile.id)) })
379
+ }
380
+ )
341
381
  ] });
342
382
  }
343
383
 
@@ -365,39 +405,46 @@ function CaptureSheet({
365
405
  className: "absolute inset-0 bg-black/30"
366
406
  }
367
407
  ),
368
- /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "relative mx-2 mb-2 mb-safe rounded-[2rem] bg-[#f2f2f7] px-6 pb-6 pt-5 text-black shadow-2xl", children: [
369
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
370
- "button",
371
- {
372
- type: "button",
373
- onClick: onClose,
374
- "aria-label": "Close",
375
- className: "absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/5 text-black/70 transition-colors hover:bg-black/10",
376
- children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react.X, { className: "h-5 w-5", strokeWidth: 2.5 })
377
- }
378
- ),
379
- variant === "busy" ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex min-h-[220px] items-center justify-center gap-2.5", children: [
380
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react.Loader2, { className: "h-5 w-5 animate-spin text-black/50" }),
381
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "text-[17px] font-medium text-black/80", children: busyLabel })
382
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "pt-4", children: [
383
- icon && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "mb-5 text-[#0a84ff]", children: icon }),
384
- title && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("h2", { className: "mb-2 text-[26px] font-bold leading-tight", children: title }),
385
- body && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "text-[17px] leading-snug text-black/85", children: body }),
386
- actions.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "mt-7 flex flex-col gap-3", children: actions.map((action) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
387
- "button",
388
- {
389
- type: "button",
390
- onClick: action.onPress,
391
- className: cn(
392
- "h-[50px] w-full touch-manipulation rounded-full text-[17px] font-semibold transition-transform active:scale-[0.98]",
393
- (action.kind ?? "primary") === "primary" ? "bg-[#0a84ff] text-white" : "bg-black/[0.06] text-[#0a84ff]"
394
- ),
395
- children: action.label
396
- },
397
- action.label
398
- )) })
399
- ] })
400
- ] })
408
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
409
+ "div",
410
+ {
411
+ className: "relative mx-2 mb-2 rounded-[2rem] bg-[#f2f2f7] px-6 pb-6 pt-5 text-black shadow-2xl",
412
+ style: { marginBottom: "calc(0.5rem + env(safe-area-inset-bottom, 0px))" },
413
+ children: [
414
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
415
+ "button",
416
+ {
417
+ type: "button",
418
+ onClick: onClose,
419
+ "aria-label": "Close",
420
+ className: "absolute right-4 top-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/5 text-black/70 transition-colors hover:bg-black/10",
421
+ children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react.X, { className: "h-5 w-5", strokeWidth: 2.5 })
422
+ }
423
+ ),
424
+ variant === "busy" ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex min-h-[220px] items-center justify-center gap-2.5", children: [
425
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react.Loader2, { className: "h-5 w-5 animate-spin text-black/50" }),
426
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "text-[17px] font-medium text-black/80", children: busyLabel })
427
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "pt-4", children: [
428
+ icon && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "mb-5 text-[#0a84ff]", children: icon }),
429
+ title && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("h2", { className: "mb-2 text-[26px] font-bold leading-tight", children: title }),
430
+ body && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "text-[17px] leading-snug text-black/85", children: body }),
431
+ actions.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "mt-7 flex flex-col gap-3", children: actions.map((action) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
432
+ "button",
433
+ {
434
+ type: "button",
435
+ onClick: action.onPress,
436
+ className: cn(
437
+ "h-[50px] w-full touch-manipulation rounded-full text-[17px] font-semibold transition-transform active:scale-[0.98]",
438
+ (action.kind ?? "primary") === "primary" ? "bg-[#0a84ff] text-white" : "bg-black/[0.06] text-[#0a84ff]"
439
+ ),
440
+ children: action.label
441
+ },
442
+ action.label
443
+ )) })
444
+ ] })
445
+ ]
446
+ }
447
+ )
401
448
  ] });
402
449
  }
403
450
 
@@ -427,303 +474,498 @@ function CountdownOverlay({ seconds }) {
427
474
  ) });
428
475
  }
429
476
 
430
- // src/components/CameraCapture.tsx
477
+ // src/media/media-cache.ts
478
+ var import_react2 = require("react");
479
+ var MAX_ENTRIES = 80;
480
+ var cache = /* @__PURE__ */ new Map();
481
+ var listeners = /* @__PURE__ */ new Map();
482
+ function notify(key) {
483
+ listeners.get(key)?.forEach((fn) => fn());
484
+ }
485
+ function evictIfNeeded() {
486
+ while (cache.size > MAX_ENTRIES) {
487
+ const oldest = cache.keys().next().value;
488
+ if (oldest === void 0) return;
489
+ const entry = cache.get(oldest);
490
+ cache.delete(oldest);
491
+ if (entry?.revoke && entry.url) URL.revokeObjectURL(entry.url);
492
+ }
493
+ }
494
+ function touch(key, entry) {
495
+ cache.delete(key);
496
+ cache.set(key, entry);
497
+ }
498
+ function getMediaUrl(item) {
499
+ if (item.src) return item.src;
500
+ if (!item.resolve) return null;
501
+ const existing = cache.get(item.key);
502
+ if (existing) {
503
+ touch(item.key, existing);
504
+ if (existing.url || existing.error) return existing.url;
505
+ return null;
506
+ }
507
+ const entry = { url: null, revoke: false, promise: null, error: false };
508
+ cache.set(item.key, entry);
509
+ evictIfNeeded();
510
+ entry.promise = item.resolve().then((resolved) => {
511
+ const url = typeof resolved === "string" ? resolved : resolved.url;
512
+ entry.revoke = typeof resolved === "object" && resolved.revoke === true;
513
+ entry.url = url;
514
+ entry.promise = null;
515
+ notify(item.key);
516
+ return url;
517
+ }).catch(() => {
518
+ entry.error = true;
519
+ entry.promise = null;
520
+ notify(item.key);
521
+ return null;
522
+ });
523
+ return null;
524
+ }
525
+ function invalidateMedia(key) {
526
+ const entry = cache.get(key);
527
+ cache.delete(key);
528
+ if (entry?.revoke && entry.url) URL.revokeObjectURL(entry.url);
529
+ notify(key);
530
+ }
531
+ function useMediaUrl(item) {
532
+ const [, bump] = (0, import_react2.useState)(0);
533
+ const key = item?.key ?? null;
534
+ (0, import_react2.useEffect)(() => {
535
+ if (!key) return;
536
+ const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
537
+ const fn = () => bump((n) => n + 1);
538
+ set.add(fn);
539
+ listeners.set(key, set);
540
+ return () => {
541
+ set.delete(fn);
542
+ if (set.size === 0) listeners.delete(key);
543
+ };
544
+ }, [key]);
545
+ if (!item) return null;
546
+ return getMediaUrl(item);
547
+ }
548
+ function primeMedia(items) {
549
+ for (const item of items) {
550
+ if (item) void getMediaUrl(item);
551
+ }
552
+ }
553
+
554
+ // src/components/CaptureFilmstrip.tsx
431
555
  var import_jsx_runtime8 = require("react/jsx-runtime");
432
- function formatElapsed(totalSeconds) {
433
- const m = Math.floor(totalSeconds / 60);
434
- const s = String(totalSeconds % 60).padStart(2, "0");
435
- return `${m}:${s}`;
556
+ function CaptureFilmstrip({
557
+ items,
558
+ onOpen,
559
+ limit = 12
560
+ }) {
561
+ if (items.length === 0) return null;
562
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "flex items-center gap-1.5 overflow-x-auto py-1", children: items.slice(-limit).map((item) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
563
+ "button",
564
+ {
565
+ type: "button",
566
+ onClick: () => onOpen(item),
567
+ "aria-label": "View capture",
568
+ className: cn(
569
+ "relative h-12 w-9 shrink-0 touch-manipulation overflow-hidden rounded bg-white/10",
570
+ item.accent && "ring-2 ring-inset ring-amber-400"
571
+ ),
572
+ children: [
573
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(FilmstripThumb, { item }),
574
+ item.status === "uploading" && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "absolute inset-0 flex items-center justify-center bg-black/40", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/40 border-t-white" }) }),
575
+ item.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "absolute inset-0 rounded ring-2 ring-inset ring-red-500" })
576
+ ]
577
+ },
578
+ item.key
579
+ )) });
436
580
  }
437
- var ASPECT_CYCLE = ["full", "4:3", "1:1", "16:9"];
438
- function CameraCapture({
439
- engine,
440
- cloud,
441
- mode,
442
- onModeChange,
443
- preview,
581
+ function FilmstripThumb({ item }) {
582
+ const url = useMediaUrl(item);
583
+ if (item.kind === "audio") {
584
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "flex h-full w-full items-center justify-center", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
585
+ "svg",
586
+ {
587
+ viewBox: "0 0 24 24",
588
+ className: "h-4 w-4 stroke-white/80",
589
+ fill: "none",
590
+ strokeWidth: 2,
591
+ strokeLinecap: "round",
592
+ "aria-hidden": true,
593
+ children: [
594
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("path", { d: "M12 3v10" }),
595
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("path", { d: "M8 7v4M16 7v4M4 9v1M20 9v1" }),
596
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("circle", { cx: "12", cy: "17", r: "3" })
597
+ ]
598
+ }
599
+ ) });
600
+ }
601
+ if (!url) return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "block h-full w-full bg-white/5" });
602
+ if (item.kind === "video") {
603
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "relative block h-full w-full", children: [
604
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
605
+ "video",
606
+ {
607
+ src: url,
608
+ muted: true,
609
+ playsInline: true,
610
+ preload: "metadata",
611
+ className: "h-full w-full object-cover"
612
+ }
613
+ ),
614
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
615
+ "svg",
616
+ {
617
+ viewBox: "0 0 24 24",
618
+ className: "absolute inset-0 m-auto h-4 w-4 fill-white drop-shadow",
619
+ "aria-hidden": true,
620
+ children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("path", { d: "M8 5v14l11-7z" })
621
+ }
622
+ )
623
+ ] });
624
+ }
625
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("img", { src: url, alt: "", className: "h-full w-full object-cover" });
626
+ }
627
+
628
+ // src/components/MediaViewer.tsx
629
+ var import_react3 = require("react");
630
+ var import_lucide_react2 = require("lucide-react");
631
+ var import_jsx_runtime9 = require("react/jsx-runtime");
632
+ var SWIPE_TRIGGER_PX = 40;
633
+ var SWIPE_TRIGGER_VELOCITY = 0.25;
634
+ var DISMISS_TRIGGER_PX = 110;
635
+ var DIRECTION_LOCK_PX = 8;
636
+ function MediaViewer({
637
+ items,
638
+ initialKey,
444
639
  onClose,
445
- blockedSheet,
446
- controlsHidden = false,
447
- shutterDisabled = false,
448
- slots = {}
640
+ onDelete,
641
+ onEdit,
642
+ deleteConsequence = "It is removed from this capture for everyone.",
643
+ keysDisabled = false
449
644
  }) {
450
- const [optionsOpen, setOptionsOpen] = (0, import_react2.useState)(false);
451
- const [gridOn, setGridOn] = (0, import_react2.useState)(false);
452
- const [timerSetting, setTimerSetting] = (0, import_react2.useState)(0);
453
- const [aspect, setAspect] = (0, import_react2.useState)("full");
454
- const [exposureOpen, setExposureOpen] = (0, import_react2.useState)(false);
455
- const [countdown, setCountdown] = (0, import_react2.useState)(null);
456
- const [blockedDismissed, setBlockedDismissed] = (0, import_react2.useState)(false);
457
- const countdownRef = (0, import_react2.useRef)(null);
458
- const controls = useTrackControls(engine.stream);
459
- const clearCountdown = (0, import_react2.useCallback)(() => {
460
- if (countdownRef.current) clearInterval(countdownRef.current);
461
- countdownRef.current = null;
462
- setCountdown(null);
463
- }, []);
464
- (0, import_react2.useEffect)(() => clearCountdown, [clearCountdown]);
465
- const onShutter = (0, import_react2.useCallback)(() => {
466
- if (mode === "video") {
467
- if (engine.recording) engine.onStopRecording();
468
- else engine.onStartRecording();
469
- return;
470
- }
471
- if (countdown !== null) {
472
- clearCountdown();
473
- return;
645
+ const count = items.length;
646
+ const [index, setIndex] = (0, import_react3.useState)(() => {
647
+ const i = items.findIndex((m) => m.key === initialKey);
648
+ return i >= 0 ? i : 0;
649
+ });
650
+ const clamped = Math.min(index, count - 1);
651
+ const current = count > 0 ? items[clamped] : void 0;
652
+ const [confirmingDelete, setConfirmingDelete] = (0, import_react3.useState)(false);
653
+ const dragRef = (0, import_react3.useRef)(null);
654
+ const [drag, setDrag] = (0, import_react3.useState)(null);
655
+ const [settling, setSettling] = (0, import_react3.useState)(false);
656
+ const go = (0, import_react3.useCallback)(
657
+ (dir) => {
658
+ setIndex((i) => Math.max(0, Math.min(count - 1, i + dir)));
659
+ setDrag(null);
660
+ setSettling(true);
661
+ },
662
+ [count]
663
+ );
664
+ (0, import_react3.useEffect)(() => {
665
+ if (count === 0) {
666
+ const t = setTimeout(onClose, 0);
667
+ return () => clearTimeout(t);
474
668
  }
475
- if (timerSetting === 0) {
476
- engine.onCapturePhoto({ aspect });
477
- return;
669
+ if (index >= count) setIndex(count - 1);
670
+ }, [count, index, onClose]);
671
+ (0, import_react3.useEffect)(() => {
672
+ setConfirmingDelete(false);
673
+ }, [clamped]);
674
+ (0, import_react3.useEffect)(() => {
675
+ primeMedia([
676
+ items[clamped - 2],
677
+ items[clamped - 1],
678
+ items[clamped + 1],
679
+ items[clamped + 2]
680
+ ]);
681
+ }, [items, clamped]);
682
+ (0, import_react3.useEffect)(() => {
683
+ if (keysDisabled) return;
684
+ const onKey = (e) => {
685
+ if (e.key === "ArrowRight") go(1);
686
+ else if (e.key === "ArrowLeft") go(-1);
687
+ else if (e.key === "Escape") onClose();
688
+ };
689
+ window.addEventListener("keydown", onKey);
690
+ return () => window.removeEventListener("keydown", onKey);
691
+ }, [go, onClose, keysDisabled]);
692
+ const onPointerDown = (0, import_react3.useCallback)((e) => {
693
+ if (e.target.closest("video, audio")) return;
694
+ dragRef.current = {
695
+ x0: e.clientX,
696
+ y0: e.clientY,
697
+ t0: performance.now(),
698
+ axis: null,
699
+ pointerId: e.pointerId
700
+ };
701
+ e.currentTarget.setPointerCapture(e.pointerId);
702
+ setSettling(false);
703
+ }, []);
704
+ const onPointerMove = (0, import_react3.useCallback)((e) => {
705
+ const d = dragRef.current;
706
+ if (!d || e.pointerId !== d.pointerId) return;
707
+ const dx = e.clientX - d.x0;
708
+ const dy = e.clientY - d.y0;
709
+ if (d.axis === null) {
710
+ if (Math.abs(dx) < DIRECTION_LOCK_PX && Math.abs(dy) < DIRECTION_LOCK_PX)
711
+ return;
712
+ d.axis = Math.abs(dx) >= Math.abs(dy) ? "x" : "y";
478
713
  }
479
- let remaining = timerSetting;
480
- setCountdown(remaining);
481
- countdownRef.current = setInterval(() => {
482
- remaining -= 1;
483
- if (remaining <= 0) {
484
- clearCountdown();
485
- engine.onCapturePhoto({ aspect });
486
- } else {
487
- setCountdown(remaining);
488
- }
489
- }, 1e3);
490
- }, [mode, engine, countdown, timerSetting, aspect, clearCountdown]);
491
- const cycleTimer = (0, import_react2.useCallback)(() => {
492
- setTimerSetting((t) => t === 0 ? 3 : t === 3 ? 10 : 0);
714
+ setDrag(d.axis === "x" ? { dx, dy: 0 } : { dx: 0, dy });
493
715
  }, []);
494
- const coreTiles = [
495
- ...controls.torchSupported ? [
496
- {
497
- id: "flash",
498
- label: "Flash",
499
- icon: controls.torchOn ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.Zap, { className: "h-6 w-6", fill: "currentColor" }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.ZapOff, { className: "h-6 w-6" }),
500
- active: controls.torchOn,
501
- onPress: controls.toggleTorch
716
+ const endDrag = (0, import_react3.useCallback)(
717
+ (e) => {
718
+ const d = dragRef.current;
719
+ if (!d || e.pointerId !== d.pointerId) return;
720
+ dragRef.current = null;
721
+ const dx = e.clientX - d.x0;
722
+ const dy = e.clientY - d.y0;
723
+ const dt = Math.max(1, performance.now() - d.t0);
724
+ const vx = dx / dt;
725
+ if (d.axis === "y" && Math.abs(dy) > DISMISS_TRIGGER_PX) {
726
+ onClose();
727
+ return;
502
728
  }
503
- ] : [],
504
- {
505
- id: "timer",
506
- label: "Timer",
507
- icon: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.Timer, { className: "h-6 w-6" }),
508
- active: timerSetting !== 0,
509
- valueLabel: timerSetting === 0 ? void 0 : `${timerSetting}s`,
510
- onPress: cycleTimer
511
- },
512
- {
513
- id: "grid",
514
- label: "Grid",
515
- icon: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.Grid3x3, { className: "h-6 w-6" }),
516
- active: gridOn,
517
- onPress: () => setGridOn((g) => !g)
518
- },
519
- // Aspect applies to PHOTO output (center-cropped from the full sensor).
520
- {
521
- id: "aspect",
522
- label: "Aspect",
523
- icon: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.Proportions, { className: "h-6 w-6" }),
524
- active: aspect !== "full",
525
- valueLabel: aspect === "full" ? void 0 : aspect,
526
- onPress: () => setAspect(
527
- (a) => ASPECT_CYCLE[(ASPECT_CYCLE.indexOf(a) + 1) % ASPECT_CYCLE.length] ?? "full"
528
- )
529
- },
530
- ...controls.exposureSupported ? [
531
- {
532
- id: "exposure",
533
- label: "Exposure",
534
- icon: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.SunMedium, { className: "h-6 w-6" }),
535
- active: controls.exposure !== 0 || exposureOpen,
536
- valueLabel: controls.exposure === 0 ? void 0 : `${controls.exposure > 0 ? "+" : ""}${controls.exposure}`,
537
- onPress: () => setExposureOpen((o) => !o)
729
+ if (d.axis === "x") {
730
+ const flick = Math.abs(dx) > 10 && Math.abs(vx) > SWIPE_TRIGGER_VELOCITY;
731
+ if ((dx < -SWIPE_TRIGGER_PX || flick && dx < 0) && clamped < count - 1) {
732
+ go(1);
733
+ return;
734
+ }
735
+ if ((dx > SWIPE_TRIGGER_PX || flick && dx > 0) && clamped > 0) {
736
+ go(-1);
737
+ return;
738
+ }
538
739
  }
539
- ] : []
540
- ];
541
- const tiles = [...coreTiles, ...slots.optionTiles ?? []];
542
- const blocked = engine.blocked !== null;
543
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "absolute inset-0 select-none overflow-hidden bg-black", children: [
544
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "absolute inset-0", children: preview }),
545
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(GridOverlay, { visible: gridOn && !blocked }),
546
- mode === "photo" && aspect !== "full" && !blocked && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
740
+ setDrag(null);
741
+ setSettling(true);
742
+ },
743
+ [clamped, count, go, onClose]
744
+ );
745
+ const neighborKeys = (0, import_react3.useMemo)(() => {
746
+ const wanted = [];
747
+ for (const i of [clamped - 1, clamped + 1]) {
748
+ const m = items[i];
749
+ if (m) wanted.push(m);
750
+ }
751
+ return wanted;
752
+ }, [items, clamped]);
753
+ if (!current) return null;
754
+ const canEdit = onEdit && current.kind === "photo";
755
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "absolute inset-0 z-40 flex flex-col bg-black", children: [
756
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
547
757
  "div",
548
758
  {
549
- "aria-hidden": true,
550
- className: "pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
551
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
552
- "div",
553
- {
554
- className: "shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]",
555
- style: {
556
- aspectRatio: aspect === "1:1" ? "1 / 1" : aspect === "4:3" ? "3 / 4" : "9 / 16",
557
- width: aspect === "16:9" ? "100%" : void 0,
558
- height: aspect === "16:9" ? void 0 : "70%",
559
- maxWidth: "100%",
560
- maxHeight: "100%"
759
+ className: "absolute inset-x-0 top-0 z-20 flex items-center justify-between bg-gradient-to-b from-black/70 to-transparent p-3",
760
+ style: safeTop,
761
+ children: [
762
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
763
+ "button",
764
+ {
765
+ type: "button",
766
+ onClick: onClose,
767
+ "aria-label": "Close viewer",
768
+ className: "flex h-10 w-10 touch-manipulation items-center justify-center rounded-full text-white transition-colors hover:bg-white/10",
769
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react2.X, { className: "h-5 w-5" })
561
770
  }
562
- }
563
- )
771
+ ),
772
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("span", { className: "rounded-full bg-black/50 px-3 py-1 text-sm tabular-nums text-white/90", children: [
773
+ clamped + 1,
774
+ " / ",
775
+ count
776
+ ] }),
777
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("span", { className: "flex items-center gap-1", children: [
778
+ canEdit ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
779
+ "button",
780
+ {
781
+ type: "button",
782
+ onClick: () => onEdit(current),
783
+ "aria-label": "Edit this photo",
784
+ className: "flex h-10 w-10 touch-manipulation items-center justify-center rounded-full text-white transition-colors hover:bg-white/10",
785
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react2.Pencil, { className: "h-5 w-5" })
786
+ }
787
+ ) : null,
788
+ onDelete ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
789
+ "button",
790
+ {
791
+ type: "button",
792
+ onClick: () => setConfirmingDelete(true),
793
+ "aria-label": "Delete this file",
794
+ className: "flex h-10 w-10 touch-manipulation items-center justify-center rounded-full text-white transition-colors hover:bg-white/10",
795
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react2.Trash2, { className: "h-5 w-5" })
796
+ }
797
+ ) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "h-10 w-10", "aria-hidden": true })
798
+ ] })
799
+ ]
564
800
  }
565
801
  ),
566
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(CountdownOverlay, { seconds: countdown }),
567
- !controlsHidden && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "absolute inset-x-0 top-0 z-20 bg-black/65 pt-safe backdrop-blur-[2px]", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "flex h-11 items-center gap-0.5 px-1.5", children: [
568
- onClose ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
569
- "button",
802
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: [
803
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
804
+ "div",
570
805
  {
571
- type: "button",
572
- onClick: onClose,
573
- "aria-label": "Close camera",
574
- className: "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full text-white transition-colors hover:bg-white/10",
575
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.X, { className: "h-5 w-5" })
806
+ className: "absolute inset-0 touch-none",
807
+ style: {
808
+ transform: drag ? `translate(${drag.dx}px, ${drag.dy}px)` : "translate(0px, 0px)",
809
+ transition: settling ? "transform 220ms cubic-bezier(0.2, 0.8, 0.3, 1)" : "none",
810
+ opacity: drag?.dy ? Math.max(0.4, 1 - Math.abs(drag.dy) / 400) : 1
811
+ },
812
+ onPointerDown,
813
+ onPointerMove,
814
+ onPointerUp: endDrag,
815
+ onPointerCancel: endDrag,
816
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ViewerSlide, { item: current, active: true }, current.key)
817
+ }
818
+ ),
819
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
820
+ "div",
821
+ {
822
+ "aria-hidden": true,
823
+ className: "pointer-events-none absolute inset-0 opacity-0",
824
+ children: neighborKeys.map((m) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "absolute inset-0", children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ViewerSlide, { item: m, active: false }) }, m.key))
576
825
  }
577
- ) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "w-10 shrink-0" }),
578
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "min-w-0 flex-1", children: slots.topBarCenter }),
579
- controls.torchSupported && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
826
+ ),
827
+ clamped > 0 && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
580
828
  "button",
581
829
  {
582
830
  type: "button",
583
- onClick: controls.toggleTorch,
584
- "aria-label": controls.torchOn ? "Turn flash off" : "Turn flash on",
585
- "aria-pressed": controls.torchOn,
586
- className: cn(
587
- "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full transition-colors",
588
- controls.torchOn ? "text-[#FFCC00]" : "text-white hover:bg-white/10"
589
- ),
590
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
591
- import_lucide_react2.Zap,
592
- {
593
- className: "h-5 w-5",
594
- fill: controls.torchOn ? "currentColor" : "none"
595
- }
596
- )
831
+ onClick: () => go(-1),
832
+ "aria-label": "Previous file",
833
+ className: "absolute left-2 top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60 sm:flex",
834
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react2.ChevronLeft, { className: "h-6 w-6" })
597
835
  }
598
836
  ),
599
- slots.topBarTrailing,
600
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
837
+ clamped < count - 1 && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
601
838
  "button",
602
839
  {
603
840
  type: "button",
604
- onClick: () => setOptionsOpen((o) => !o),
605
- "aria-label": "More camera options",
606
- "aria-expanded": optionsOpen,
607
- className: cn(
608
- "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full transition-colors",
609
- optionsOpen ? "bg-white/20 text-white" : "text-white hover:bg-white/10"
610
- ),
611
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.Grip, { className: "h-5 w-5" })
841
+ onClick: () => go(1),
842
+ "aria-label": "Next file",
843
+ className: "absolute right-2 top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60 sm:flex",
844
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react2.ChevronRight, { className: "h-6 w-6" })
612
845
  }
613
846
  )
614
- ] }) }),
615
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "pointer-events-none absolute inset-x-0 top-[50px] z-20 mt-safe flex flex-col items-center gap-1.5", children: [
616
- engine.recording && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm font-medium text-white", children: [
617
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "h-2.5 w-2.5 animate-pulse rounded-full bg-[#FF3B30]" }),
618
- formatElapsed(engine.recordElapsedSeconds)
619
- ] }),
620
- slots.statusChips
621
847
  ] }),
622
- !controlsHidden && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "absolute inset-x-0 bottom-0 z-20", children: [
623
- !blocked && controls.zoomOptions.length >= 2 && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "mb-2", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
624
- ZoomRow,
625
- {
626
- options: controls.zoomOptions,
627
- value: controls.zoom,
628
- onSelect: controls.setZoom
629
- }
630
- ) }),
631
- slots.aboveBar && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "mb-1.5 px-2", children: slots.aboveBar }),
632
- exposureOpen && controls.exposureSupported && controls.exposureRange && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "mx-auto mb-3 flex w-64 items-center gap-3 rounded-full bg-black/55 px-4 py-2", children: [
633
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.SunMedium, { className: "h-4 w-4 shrink-0 text-[#FFCC00]" }),
634
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
635
- "input",
848
+ confirmingDelete && onDelete && /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
849
+ "div",
850
+ {
851
+ className: "absolute inset-x-0 bottom-0 z-30 bg-black/85 px-4 pt-4 backdrop-blur-sm",
852
+ style: {
853
+ paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))"
854
+ },
855
+ children: [
856
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("p", { className: "pb-3 text-center text-sm text-white/85", children: [
857
+ "Delete this ",
858
+ current.kind === "video" ? "video" : current.kind === "audio" ? "voice note" : "photo",
859
+ "? ",
860
+ deleteConsequence
861
+ ] }),
862
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "flex items-center justify-center gap-3", children: [
863
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
864
+ "button",
865
+ {
866
+ type: "button",
867
+ onClick: () => setConfirmingDelete(false),
868
+ className: "h-11 touch-manipulation rounded-full bg-white/15 px-6 text-sm font-medium text-white",
869
+ children: "Keep it"
870
+ }
871
+ ),
872
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
873
+ "button",
874
+ {
875
+ type: "button",
876
+ onClick: () => {
877
+ setConfirmingDelete(false);
878
+ onDelete(current);
879
+ },
880
+ className: "h-11 touch-manipulation rounded-full bg-[#FF3B30] px-6 text-sm font-semibold text-white",
881
+ children: "Delete"
882
+ }
883
+ )
884
+ ] })
885
+ ]
886
+ }
887
+ ),
888
+ count > 1 && count <= 12 && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
889
+ "div",
890
+ {
891
+ className: "pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center gap-1.5",
892
+ style: safeMarginBottom,
893
+ children: items.map((m, i) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
894
+ "span",
636
895
  {
637
- type: "range",
638
- min: controls.exposureRange.min,
639
- max: controls.exposureRange.max,
640
- step: controls.exposureRange.step,
641
- value: controls.exposure,
642
- onChange: (e) => controls.setExposure(Number(e.target.value)),
643
- "aria-label": "Exposure compensation",
644
- className: "w-full accent-[#FFCC00]"
645
- }
646
- ),
647
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { className: "w-8 shrink-0 text-right text-xs tabular-nums text-white", children: [
648
- controls.exposure > 0 ? "+" : "",
649
- controls.exposure
650
- ] })
651
- ] }),
652
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "bg-black/65 px-3 pb-safe backdrop-blur-[2px]", children: [
653
- slots.aboveModeSelector,
654
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "flex items-center justify-center gap-2 py-1.5", children: [
655
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
656
- ModeSelector,
657
- {
658
- mode,
659
- onModeChange,
660
- onUpload: engine.onUpload,
661
- modeDisabled: engine.recording,
662
- uploadDisabled: shutterDisabled && !blocked,
663
- extraModes: slots.extraModes
664
- }
665
- ),
666
- slots.modeRowTrailing && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "shrink-0", children: slots.modeRowTrailing })
667
- ] }),
668
- /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "flex items-center justify-between px-2 pb-1.5 pt-0.5", children: [
669
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "flex w-14 justify-start", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
670
- "button",
671
- {
672
- type: "button",
673
- onClick: cloud.onOpenLibrary,
674
- "aria-label": "Open your media library",
675
- className: "h-11 w-11 touch-manipulation overflow-hidden rounded-xl bg-white/10 ring-1 ring-white/25 transition-transform active:scale-95",
676
- children: cloud.recentsThumb ?? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "block h-full w-full bg-white/5" })
677
- }
678
- ) }),
679
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
680
- ShutterButton,
681
- {
682
- mode,
683
- recording: engine.recording,
684
- disabled: shutterDisabled || blocked,
685
- onPress: onShutter
686
- }
687
- ),
688
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "flex w-14 justify-end", children: engine.onFlipCamera ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
689
- "button",
690
- {
691
- type: "button",
692
- onClick: engine.onFlipCamera,
693
- "aria-label": "Switch camera",
694
- className: "flex h-11 w-11 touch-manipulation items-center justify-center rounded-full bg-white/15 text-white transition-transform active:rotate-180 active:scale-95 duration-300",
695
- children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_lucide_react2.RefreshCw, { className: "h-5 w-5" })
696
- }
697
- ) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "h-11 w-11" }) })
698
- ] })
699
- ] })
700
- ] }),
701
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
702
- OptionsGridPanel,
703
- {
704
- open: optionsOpen && !controlsHidden,
705
- onClose: () => setOptionsOpen(false),
706
- tiles
896
+ className: cn(
897
+ "h-1.5 w-1.5 rounded-full transition-colors",
898
+ i === clamped ? "bg-white" : "bg-white/35"
899
+ )
900
+ },
901
+ m.key
902
+ ))
707
903
  }
708
- ),
709
- blocked && blockedSheet && !blockedDismissed && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
710
- CaptureSheet,
904
+ )
905
+ ] });
906
+ }
907
+ function ViewerSlide({
908
+ item,
909
+ active
910
+ }) {
911
+ const url = useMediaUrl(item);
912
+ if (!url) {
913
+ return active ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "h-8 w-8 animate-spin rounded-full border-2 border-white/30 border-t-white" }) }) : null;
914
+ }
915
+ if (item.kind === "audio") {
916
+ if (!active) return null;
917
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "absolute inset-0 flex flex-col items-center justify-center gap-6 px-8", children: [
918
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
919
+ "svg",
920
+ {
921
+ viewBox: "0 0 24 24",
922
+ className: "h-12 w-12 stroke-white/80",
923
+ fill: "none",
924
+ strokeWidth: 1.5,
925
+ strokeLinecap: "round",
926
+ "aria-hidden": true,
927
+ children: [
928
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("path", { d: "M12 2v12" }),
929
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("path", { d: "M7 6v6M17 6v6M2 9v2M22 9v2" }),
930
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("circle", { cx: "12", cy: "19", r: "3" })
931
+ ]
932
+ }
933
+ ),
934
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("audio", { src: url, controls: true, className: "w-full max-w-sm" })
935
+ ] });
936
+ }
937
+ if (item.kind === "video") {
938
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
939
+ "video",
711
940
  {
712
- open: true,
713
- onClose: () => setBlockedDismissed(true),
714
- body: blockedSheet.body,
715
- title: "Camera unavailable",
716
- actions: blockedSheet.actions
941
+ src: url,
942
+ controls: active,
943
+ playsInline: true,
944
+ preload: active ? "auto" : "metadata",
945
+ muted: !active,
946
+ className: cn(
947
+ "absolute inset-0 h-full w-full object-contain",
948
+ active ? "pointer-events-auto" : "pointer-events-none"
949
+ )
717
950
  }
718
- ),
719
- slots.overlays
720
- ] });
951
+ );
952
+ }
953
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
954
+ "img",
955
+ {
956
+ src: url,
957
+ alt: "",
958
+ draggable: false,
959
+ decoding: active ? "auto" : "async",
960
+ className: "absolute inset-0 h-full w-full select-none object-contain"
961
+ }
962
+ );
721
963
  }
722
964
 
723
965
  // src/components/ImageEditSheet.tsx
724
- var import_react3 = require("react");
966
+ var import_react4 = require("react");
725
967
  var import_lucide_react3 = require("lucide-react");
726
- var import_jsx_runtime9 = require("react/jsx-runtime");
968
+ var import_jsx_runtime10 = require("react/jsx-runtime");
727
969
  var ASPECTS = [
728
970
  { id: "free", label: "Free", ratio: null },
729
971
  { id: "1:1", label: "Square", ratio: 1 },
@@ -738,43 +980,83 @@ function ImageEditSheet({
738
980
  onClose,
739
981
  onSave
740
982
  }) {
741
- const [rotation, setRotation] = (0, import_react3.useState)(0);
742
- const [flipped, setFlipped] = (0, import_react3.useState)(false);
743
- const [aspect, setAspect] = (0, import_react3.useState)("free");
744
- const [crop, setCrop] = (0, import_react3.useState)(FULL_CROP);
745
- const [saving, setSaving] = (0, import_react3.useState)(false);
746
- const stageRef = (0, import_react3.useRef)(null);
747
- const imgRef = (0, import_react3.useRef)(null);
748
- const dragRef = (0, import_react3.useRef)(null);
749
- (0, import_react3.useEffect)(() => {
983
+ const [workingSrc, setWorkingSrc] = (0, import_react4.useState)(null);
984
+ const [aspect, setAspect] = (0, import_react4.useState)("free");
985
+ const [crop, setCrop] = (0, import_react4.useState)(FULL_CROP);
986
+ const [saving, setSaving] = (0, import_react4.useState)(false);
987
+ const [editError, setEditError] = (0, import_react4.useState)(null);
988
+ const stageRef = (0, import_react4.useRef)(null);
989
+ const imgRef = (0, import_react4.useRef)(null);
990
+ const ownedUrlsRef = (0, import_react4.useRef)([]);
991
+ const dragRef = (0, import_react4.useRef)(null);
992
+ const revokeOwned = (0, import_react4.useCallback)(() => {
993
+ for (const url of ownedUrlsRef.current) URL.revokeObjectURL(url);
994
+ ownedUrlsRef.current = [];
995
+ }, []);
996
+ (0, import_react4.useEffect)(() => {
750
997
  if (open) {
751
- setRotation(0);
752
- setFlipped(false);
998
+ setWorkingSrc(null);
753
999
  setAspect("free");
754
1000
  setCrop(FULL_CROP);
755
1001
  setSaving(false);
1002
+ setEditError(null);
756
1003
  }
757
- }, [open, src]);
758
- const applyAspect = (0, import_react3.useCallback)((preset) => {
1004
+ return revokeOwned;
1005
+ }, [open, src, revokeOwned]);
1006
+ const bake = (0, import_react4.useCallback)(
1007
+ (op) => {
1008
+ const img = imgRef.current;
1009
+ if (!img || img.naturalWidth === 0 || saving) return;
1010
+ try {
1011
+ const swap = op === "rotate-left";
1012
+ const canvas = document.createElement("canvas");
1013
+ canvas.width = swap ? img.naturalHeight : img.naturalWidth;
1014
+ canvas.height = swap ? img.naturalWidth : img.naturalHeight;
1015
+ const ctx = canvas.getContext("2d");
1016
+ if (!ctx) throw new Error("no 2d context");
1017
+ ctx.translate(canvas.width / 2, canvas.height / 2);
1018
+ if (swap) ctx.rotate(-Math.PI / 2);
1019
+ else ctx.scale(-1, 1);
1020
+ ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);
1021
+ canvas.toBlob(
1022
+ (blob) => {
1023
+ if (!blob) {
1024
+ setEditError(
1025
+ "This image is too large to process on this device."
1026
+ );
1027
+ return;
1028
+ }
1029
+ revokeOwned();
1030
+ const url = URL.createObjectURL(blob);
1031
+ ownedUrlsRef.current.push(url);
1032
+ setWorkingSrc(url);
1033
+ setCrop(FULL_CROP);
1034
+ setAspect("free");
1035
+ setEditError(null);
1036
+ },
1037
+ "image/jpeg",
1038
+ 0.95
1039
+ );
1040
+ } catch {
1041
+ setEditError("This image could not be edited on this device.");
1042
+ }
1043
+ },
1044
+ [saving, revokeOwned]
1045
+ );
1046
+ const applyAspect = (0, import_react4.useCallback)((preset) => {
759
1047
  setAspect(preset);
760
1048
  const ratio = ASPECTS.find((a) => a.id === preset)?.ratio ?? null;
761
1049
  if (ratio === null) return;
762
1050
  const img = imgRef.current;
763
1051
  if (!img || img.naturalWidth === 0) return;
764
- const rotated = rotationSwapsAxes();
765
- const iw = rotated ? img.naturalHeight : img.naturalWidth;
766
- const ih = rotated ? img.naturalWidth : img.naturalHeight;
767
- const imageRatio = iw / ih;
1052
+ const imageRatio = img.naturalWidth / img.naturalHeight;
768
1053
  let w = 1;
769
1054
  let h = 1;
770
1055
  if (ratio > imageRatio) h = imageRatio / ratio;
771
1056
  else w = ratio / imageRatio;
772
1057
  setCrop({ x: (1 - w) / 2, y: (1 - h) / 2, w, h });
773
- function rotationSwapsAxes() {
774
- return rotation % 180 !== 0;
775
- }
776
- }, [rotation]);
777
- const onPointerDown = (0, import_react3.useCallback)(
1058
+ }, []);
1059
+ const onPointerDown = (0, import_react4.useCallback)(
778
1060
  (kind) => (e) => {
779
1061
  e.preventDefault();
780
1062
  e.stopPropagation();
@@ -788,7 +1070,7 @@ function ImageEditSheet({
788
1070
  },
789
1071
  [crop]
790
1072
  );
791
- const onPointerMove = (0, import_react3.useCallback)(
1073
+ const onPointerMove = (0, import_react4.useCallback)(
792
1074
  (e) => {
793
1075
  const drag = dragRef.current;
794
1076
  const img = imgRef.current;
@@ -826,35 +1108,31 @@ function ImageEditSheet({
826
1108
  },
827
1109
  [aspect]
828
1110
  );
829
- const onPointerUp = (0, import_react3.useCallback)(() => {
1111
+ const onPointerUp = (0, import_react4.useCallback)(() => {
830
1112
  dragRef.current = null;
831
1113
  }, []);
832
- const save = (0, import_react3.useCallback)(() => {
1114
+ const save = (0, import_react4.useCallback)(() => {
833
1115
  const img = imgRef.current;
834
1116
  if (!img || img.naturalWidth === 0) return;
835
1117
  setSaving(true);
1118
+ setEditError(null);
836
1119
  try {
837
- const swap = rotation % 180 !== 0;
838
- const outW = Math.round((swap ? img.naturalHeight : img.naturalWidth) * crop.w);
839
- const outH = Math.round((swap ? img.naturalWidth : img.naturalHeight) * crop.h);
840
1120
  const canvas = document.createElement("canvas");
841
- canvas.width = Math.max(1, outW);
842
- canvas.height = Math.max(1, outH);
1121
+ canvas.width = Math.max(1, Math.round(img.naturalWidth * crop.w));
1122
+ canvas.height = Math.max(1, Math.round(img.naturalHeight * crop.h));
843
1123
  const ctx = canvas.getContext("2d");
844
1124
  if (!ctx) throw new Error("no 2d context");
845
- const rw = swap ? img.naturalHeight : img.naturalWidth;
846
- const rh = swap ? img.naturalWidth : img.naturalHeight;
847
- ctx.translate(-crop.x * rw, -crop.y * rh);
848
- ctx.translate(rw / 2, rh / 2);
849
- ctx.rotate(rotation * Math.PI / 180);
850
- if (flipped) ctx.scale(-1, 1);
851
- ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);
1125
+ ctx.drawImage(img, -crop.x * img.naturalWidth, -crop.y * img.naturalHeight);
852
1126
  canvas.toBlob(
853
1127
  (blob) => {
854
1128
  setSaving(false);
855
1129
  if (blob) {
856
1130
  onSave(blob);
857
1131
  onClose();
1132
+ } else {
1133
+ setEditError(
1134
+ "Saving failed on this device \u2014 try a smaller crop."
1135
+ );
858
1136
  }
859
1137
  },
860
1138
  "image/jpeg",
@@ -863,41 +1141,49 @@ function ImageEditSheet({
863
1141
  } catch (err) {
864
1142
  console.error("[capture-camera] edit save failed", err);
865
1143
  setSaving(false);
1144
+ setEditError("Saving failed on this device \u2014 try again.");
866
1145
  }
867
- }, [rotation, flipped, crop, onSave, onClose]);
1146
+ }, [crop, onSave, onClose]);
868
1147
  if (!open || !src) return null;
869
- return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
870
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "flex shrink-0 items-center justify-between px-4 pt-safe", children: [
871
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
872
- "button",
873
- {
874
- type: "button",
875
- onClick: onClose,
876
- "aria-label": "Cancel editing",
877
- className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-medium text-white",
878
- children: [
879
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react3.X, { className: "h-5 w-5" }),
880
- "Cancel"
881
- ]
882
- }
883
- ),
884
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "text-[15px] font-semibold text-white/90", children: "Edit" }),
885
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
886
- "button",
887
- {
888
- type: "button",
889
- onClick: save,
890
- disabled: saving,
891
- "aria-label": "Save edited image",
892
- className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-semibold text-[#FFCC00] disabled:opacity-50",
893
- children: [
894
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react3.Check, { className: "h-5 w-5" }),
895
- "Save"
896
- ]
897
- }
898
- )
899
- ] }),
900
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1148
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
1149
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1150
+ "div",
1151
+ {
1152
+ className: "flex shrink-0 items-center justify-between px-4",
1153
+ style: { paddingTop: "env(safe-area-inset-top, 0px)" },
1154
+ children: [
1155
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1156
+ "button",
1157
+ {
1158
+ type: "button",
1159
+ onClick: onClose,
1160
+ "aria-label": "Cancel editing",
1161
+ className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-medium text-white",
1162
+ children: [
1163
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lucide_react3.X, { className: "h-5 w-5" }),
1164
+ "Cancel"
1165
+ ]
1166
+ }
1167
+ ),
1168
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: "text-[15px] font-semibold text-white/90", children: "Edit" }),
1169
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1170
+ "button",
1171
+ {
1172
+ type: "button",
1173
+ onClick: save,
1174
+ disabled: saving,
1175
+ "aria-label": "Save edited image",
1176
+ className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-semibold text-[#FFCC00] disabled:opacity-50",
1177
+ children: [
1178
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lucide_react3.Check, { className: "h-5 w-5" }),
1179
+ "Save"
1180
+ ]
1181
+ }
1182
+ )
1183
+ ]
1184
+ }
1185
+ ),
1186
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
901
1187
  "div",
902
1188
  {
903
1189
  ref: stageRef,
@@ -905,21 +1191,18 @@ function ImageEditSheet({
905
1191
  onPointerMove,
906
1192
  onPointerUp,
907
1193
  onPointerCancel: onPointerUp,
908
- children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "relative max-h-full max-w-full", children: [
909
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1194
+ children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "relative max-h-full max-w-full", children: [
1195
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
910
1196
  "img",
911
1197
  {
912
1198
  ref: imgRef,
913
- src,
1199
+ src: workingSrc ?? src,
914
1200
  alt: "Image being edited",
915
1201
  draggable: false,
916
- className: "max-h-[62dvh] max-w-full select-none object-contain",
917
- style: {
918
- transform: `rotate(${rotation}deg) ${flipped ? "scaleX(-1)" : ""}`
919
- }
1202
+ className: "max-h-[62dvh] max-w-full select-none object-contain"
920
1203
  }
921
1204
  ),
922
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1205
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
923
1206
  "div",
924
1207
  {
925
1208
  role: "presentation",
@@ -931,7 +1214,7 @@ function ImageEditSheet({
931
1214
  width: `${crop.w * 100}%`,
932
1215
  height: `${crop.h * 100}%`
933
1216
  },
934
- children: ["nw", "ne", "sw", "se"].map((corner) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1217
+ children: ["nw", "ne", "sw", "se"].map((corner) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
935
1218
  "span",
936
1219
  {
937
1220
  role: "presentation",
@@ -952,8 +1235,9 @@ function ImageEditSheet({
952
1235
  ] })
953
1236
  }
954
1237
  ),
955
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "shrink-0 pb-safe", children: [
956
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "flex items-center justify-center gap-2 pb-2", children: ASPECTS.map((a) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1238
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "shrink-0", style: { paddingBottom: "env(safe-area-inset-bottom, 0px)" }, children: [
1239
+ editError && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: "px-6 pb-2 text-center text-sm text-[#FF6961]", children: editError }),
1240
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("div", { className: "flex items-center justify-center gap-2 pb-2", children: ASPECTS.map((a) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
957
1241
  "button",
958
1242
  {
959
1243
  type: "button",
@@ -967,37 +1251,676 @@ function ImageEditSheet({
967
1251
  },
968
1252
  a.id
969
1253
  )) }),
970
- /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "flex items-center justify-center gap-6 pb-4", children: [
971
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1254
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "flex items-center justify-center gap-6 pb-4", children: [
1255
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
972
1256
  "button",
973
1257
  {
974
1258
  type: "button",
975
- onClick: () => {
976
- setRotation((r) => (r + 270) % 360);
977
- setCrop(FULL_CROP);
978
- setAspect("free");
979
- },
1259
+ onClick: () => bake("rotate-left"),
980
1260
  "aria-label": "Rotate left",
981
1261
  className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
982
- children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react3.RotateCcw, { className: "h-5 w-5" })
1262
+ children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lucide_react3.RotateCcw, { className: "h-5 w-5" })
983
1263
  }
984
1264
  ),
985
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1265
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
986
1266
  "button",
987
1267
  {
988
1268
  type: "button",
989
- onClick: () => setFlipped((f) => !f),
1269
+ onClick: () => bake("flip"),
990
1270
  "aria-label": "Flip horizontally",
991
- "aria-pressed": flipped,
992
- className: cn(
993
- "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10",
994
- flipped ? "text-[#FFCC00]" : "text-white"
995
- ),
996
- children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_lucide_react3.FlipHorizontal2, { className: "h-5 w-5" })
1271
+ className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
1272
+ children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lucide_react3.FlipHorizontal2, { className: "h-5 w-5" })
997
1273
  }
998
1274
  )
999
1275
  ] })
1000
1276
  ] })
1001
1277
  ] });
1002
1278
  }
1279
+
1280
+ // src/components/CameraCapture.tsx
1281
+ var import_jsx_runtime11 = require("react/jsx-runtime");
1282
+ function formatElapsed(totalSeconds) {
1283
+ const m = Math.floor(totalSeconds / 60);
1284
+ const s = String(totalSeconds % 60).padStart(2, "0");
1285
+ return `${m}:${s}`;
1286
+ }
1287
+ var ASPECT_CYCLE = ["full", "4:3", "1:1", "16:9"];
1288
+ function CameraCapture({
1289
+ engine,
1290
+ cloud,
1291
+ mode,
1292
+ onModeChange,
1293
+ preview,
1294
+ media,
1295
+ onReviewOpenChange,
1296
+ onClose,
1297
+ blockedSheet,
1298
+ controlsHidden = false,
1299
+ shutterDisabled = false,
1300
+ slots = {}
1301
+ }) {
1302
+ const [optionsOpen, setOptionsOpen] = (0, import_react5.useState)(false);
1303
+ const [viewerKey, setViewerKey] = (0, import_react5.useState)(null);
1304
+ const [editTarget, setEditTarget] = (0, import_react5.useState)(null);
1305
+ const [gridOn, setGridOn] = (0, import_react5.useState)(false);
1306
+ const [timerSetting, setTimerSetting] = (0, import_react5.useState)(0);
1307
+ const [aspect, setAspect] = (0, import_react5.useState)("full");
1308
+ const [exposureOpen, setExposureOpen] = (0, import_react5.useState)(false);
1309
+ const [countdown, setCountdown] = (0, import_react5.useState)(null);
1310
+ const [blockedDismissed, setBlockedDismissed] = (0, import_react5.useState)(false);
1311
+ const countdownRef = (0, import_react5.useRef)(null);
1312
+ const controls = useTrackControls(engine.stream);
1313
+ const reviewOpen = viewerKey !== null || editTarget !== null;
1314
+ (0, import_react5.useEffect)(() => {
1315
+ onReviewOpenChange?.(reviewOpen);
1316
+ }, [reviewOpen, onReviewOpenChange]);
1317
+ const clearCountdown = (0, import_react5.useCallback)(() => {
1318
+ if (countdownRef.current) clearInterval(countdownRef.current);
1319
+ countdownRef.current = null;
1320
+ setCountdown(null);
1321
+ }, []);
1322
+ (0, import_react5.useEffect)(() => clearCountdown, [clearCountdown]);
1323
+ (0, import_react5.useEffect)(() => {
1324
+ if (mode !== "photo") clearCountdown();
1325
+ }, [mode, clearCountdown]);
1326
+ const onShutter = (0, import_react5.useCallback)(() => {
1327
+ if (mode === "video") {
1328
+ if (engine.recording) engine.onStopRecording();
1329
+ else engine.onStartRecording();
1330
+ return;
1331
+ }
1332
+ if (countdown !== null) {
1333
+ clearCountdown();
1334
+ return;
1335
+ }
1336
+ if (timerSetting === 0) {
1337
+ engine.onCapturePhoto({ aspect });
1338
+ return;
1339
+ }
1340
+ let remaining = timerSetting;
1341
+ setCountdown(remaining);
1342
+ countdownRef.current = setInterval(() => {
1343
+ remaining -= 1;
1344
+ if (remaining <= 0) {
1345
+ clearCountdown();
1346
+ engine.onCapturePhoto({ aspect });
1347
+ } else {
1348
+ setCountdown(remaining);
1349
+ }
1350
+ }, 1e3);
1351
+ }, [mode, engine, countdown, timerSetting, aspect, clearCountdown]);
1352
+ const cycleTimer = (0, import_react5.useCallback)(() => {
1353
+ setTimerSetting((t) => t === 0 ? 3 : t === 3 ? 10 : 0);
1354
+ }, []);
1355
+ const coreTiles = [
1356
+ ...controls.torchSupported ? [
1357
+ {
1358
+ id: "flash",
1359
+ label: "Flash",
1360
+ icon: controls.torchOn ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.Zap, { className: "h-6 w-6", fill: "currentColor" }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.ZapOff, { className: "h-6 w-6" }),
1361
+ active: controls.torchOn,
1362
+ onPress: controls.toggleTorch
1363
+ }
1364
+ ] : [],
1365
+ {
1366
+ id: "timer",
1367
+ label: "Timer",
1368
+ icon: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.Timer, { className: "h-6 w-6" }),
1369
+ active: timerSetting !== 0,
1370
+ valueLabel: timerSetting === 0 ? void 0 : `${timerSetting}s`,
1371
+ onPress: cycleTimer
1372
+ },
1373
+ {
1374
+ id: "grid",
1375
+ label: "Grid",
1376
+ icon: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.Grid3x3, { className: "h-6 w-6" }),
1377
+ active: gridOn,
1378
+ onPress: () => setGridOn((g) => !g)
1379
+ },
1380
+ // Aspect applies to PHOTO output (center-cropped from the full sensor).
1381
+ {
1382
+ id: "aspect",
1383
+ label: "Aspect",
1384
+ icon: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.Proportions, { className: "h-6 w-6" }),
1385
+ active: aspect !== "full",
1386
+ valueLabel: aspect === "full" ? void 0 : aspect,
1387
+ onPress: () => setAspect(
1388
+ (a) => ASPECT_CYCLE[(ASPECT_CYCLE.indexOf(a) + 1) % ASPECT_CYCLE.length] ?? "full"
1389
+ )
1390
+ },
1391
+ ...controls.exposureSupported ? [
1392
+ {
1393
+ id: "exposure",
1394
+ label: "Exposure",
1395
+ icon: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.SunMedium, { className: "h-6 w-6" }),
1396
+ active: controls.exposure !== 0 || exposureOpen,
1397
+ valueLabel: controls.exposure === 0 ? void 0 : `${controls.exposure > 0 ? "+" : ""}${controls.exposure}`,
1398
+ onPress: () => setExposureOpen((o) => !o)
1399
+ }
1400
+ ] : []
1401
+ ];
1402
+ const tiles = [...coreTiles, ...slots.optionTiles ?? []];
1403
+ const blocked = engine.blocked !== null;
1404
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "absolute inset-0 select-none overflow-hidden bg-black", children: [
1405
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "absolute inset-0", children: preview }),
1406
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(GridOverlay, { visible: gridOn && !blocked }),
1407
+ mode === "photo" && aspect !== "full" && !blocked && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1408
+ "div",
1409
+ {
1410
+ "aria-hidden": true,
1411
+ className: "pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
1412
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1413
+ "div",
1414
+ {
1415
+ className: "shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]",
1416
+ style: {
1417
+ aspectRatio: aspect === "1:1" ? "1 / 1" : aspect === "4:3" ? "3 / 4" : "9 / 16",
1418
+ width: aspect === "16:9" ? "100%" : void 0,
1419
+ height: aspect === "16:9" ? void 0 : "70%",
1420
+ maxWidth: "100%",
1421
+ maxHeight: "100%"
1422
+ }
1423
+ }
1424
+ )
1425
+ }
1426
+ ),
1427
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(CountdownOverlay, { seconds: countdown }),
1428
+ !controlsHidden && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1429
+ "div",
1430
+ {
1431
+ className: "absolute inset-x-0 top-0 z-20 bg-black/65 backdrop-blur-[2px]",
1432
+ style: safeTop,
1433
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex h-11 items-center gap-0.5 px-1.5", children: [
1434
+ onClose ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1435
+ "button",
1436
+ {
1437
+ type: "button",
1438
+ onClick: onClose,
1439
+ "aria-label": "Close camera",
1440
+ className: "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full text-white transition-colors hover:bg-white/10",
1441
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.X, { className: "h-5 w-5" })
1442
+ }
1443
+ ) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "w-10 shrink-0" }),
1444
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "min-w-0 flex-1", children: slots.topBarCenter }),
1445
+ controls.torchSupported && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1446
+ "button",
1447
+ {
1448
+ type: "button",
1449
+ onClick: controls.toggleTorch,
1450
+ "aria-label": controls.torchOn ? "Turn flash off" : "Turn flash on",
1451
+ "aria-pressed": controls.torchOn,
1452
+ className: cn(
1453
+ "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full transition-colors",
1454
+ controls.torchOn ? "text-[#FFCC00]" : "text-white hover:bg-white/10"
1455
+ ),
1456
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1457
+ import_lucide_react4.Zap,
1458
+ {
1459
+ className: "h-5 w-5",
1460
+ fill: controls.torchOn ? "currentColor" : "none"
1461
+ }
1462
+ )
1463
+ }
1464
+ ),
1465
+ slots.topBarTrailing,
1466
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1467
+ "button",
1468
+ {
1469
+ type: "button",
1470
+ onClick: () => setOptionsOpen((o) => !o),
1471
+ "aria-label": "More camera options",
1472
+ "aria-expanded": optionsOpen,
1473
+ className: cn(
1474
+ "flex h-10 w-10 shrink-0 touch-manipulation items-center justify-center rounded-full transition-colors",
1475
+ optionsOpen ? "bg-white/20 text-white" : "text-white hover:bg-white/10"
1476
+ ),
1477
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.Grip, { className: "h-5 w-5" })
1478
+ }
1479
+ )
1480
+ ] })
1481
+ }
1482
+ ),
1483
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(
1484
+ "div",
1485
+ {
1486
+ className: "pointer-events-none absolute inset-x-0 top-[50px] z-20 flex flex-col items-center gap-1.5",
1487
+ style: safeMarginTop,
1488
+ children: [
1489
+ engine.recording && /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("span", { className: "flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm font-medium text-white", children: [
1490
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "h-2.5 w-2.5 animate-pulse rounded-full bg-[#FF3B30]" }),
1491
+ formatElapsed(engine.recordElapsedSeconds)
1492
+ ] }),
1493
+ slots.statusChips
1494
+ ]
1495
+ }
1496
+ ),
1497
+ !controlsHidden && /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "absolute inset-x-0 bottom-0 z-20", children: [
1498
+ !blocked && controls.zoomOptions.length >= 2 && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "mb-2", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1499
+ ZoomRow,
1500
+ {
1501
+ options: controls.zoomOptions,
1502
+ value: controls.zoom,
1503
+ onSelect: controls.setZoom
1504
+ }
1505
+ ) }),
1506
+ media && media.items.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "mb-1.5 px-2", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1507
+ CaptureFilmstrip,
1508
+ {
1509
+ items: media.items,
1510
+ onOpen: (item) => setViewerKey(item.key)
1511
+ }
1512
+ ) }),
1513
+ slots.aboveBar && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "mb-1.5 px-2", children: slots.aboveBar }),
1514
+ exposureOpen && controls.exposureSupported && controls.exposureRange && /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "mx-auto mb-3 flex w-64 items-center gap-3 rounded-full bg-black/55 px-4 py-2", children: [
1515
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.SunMedium, { className: "h-4 w-4 shrink-0 text-[#FFCC00]" }),
1516
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1517
+ "input",
1518
+ {
1519
+ type: "range",
1520
+ min: controls.exposureRange.min,
1521
+ max: controls.exposureRange.max,
1522
+ step: controls.exposureRange.step,
1523
+ value: controls.exposure,
1524
+ onChange: (e) => controls.setExposure(Number(e.target.value)),
1525
+ "aria-label": "Exposure compensation",
1526
+ className: "w-full accent-[#FFCC00]"
1527
+ }
1528
+ ),
1529
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("span", { className: "w-8 shrink-0 text-right text-xs tabular-nums text-white", children: [
1530
+ controls.exposure > 0 ? "+" : "",
1531
+ controls.exposure
1532
+ ] })
1533
+ ] }),
1534
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(
1535
+ "div",
1536
+ {
1537
+ className: "bg-black/65 px-3 backdrop-blur-[2px]",
1538
+ style: safeBottom,
1539
+ children: [
1540
+ slots.aboveModeSelector,
1541
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex items-center justify-center gap-2 py-1.5", children: [
1542
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1543
+ ModeSelector,
1544
+ {
1545
+ mode,
1546
+ onModeChange,
1547
+ onUpload: engine.onUpload,
1548
+ modeDisabled: engine.recording,
1549
+ uploadDisabled: shutterDisabled && !blocked,
1550
+ extraModes: slots.extraModes
1551
+ }
1552
+ ),
1553
+ slots.modeRowTrailing && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "shrink-0", children: slots.modeRowTrailing })
1554
+ ] }),
1555
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex items-center justify-between px-2 pb-1.5 pt-0.5", children: [
1556
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "flex w-14 justify-start", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1557
+ "button",
1558
+ {
1559
+ type: "button",
1560
+ onClick: cloud.onOpenLibrary,
1561
+ "aria-label": "Open your media library",
1562
+ className: "h-11 w-11 touch-manipulation overflow-hidden rounded-xl bg-white/10 ring-1 ring-white/25 transition-transform active:scale-95",
1563
+ children: cloud.recentsThumb ?? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "block h-full w-full bg-white/5" })
1564
+ }
1565
+ ) }),
1566
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1567
+ ShutterButton,
1568
+ {
1569
+ mode,
1570
+ recording: engine.recording,
1571
+ disabled: shutterDisabled || blocked,
1572
+ onPress: onShutter
1573
+ }
1574
+ ),
1575
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "flex w-14 justify-end", children: engine.onFlipCamera ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1576
+ "button",
1577
+ {
1578
+ type: "button",
1579
+ onClick: engine.onFlipCamera,
1580
+ "aria-label": "Switch camera",
1581
+ className: "flex h-11 w-11 touch-manipulation items-center justify-center rounded-full bg-white/15 text-white transition-transform active:rotate-180 active:scale-95 duration-300",
1582
+ children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react4.RefreshCw, { className: "h-5 w-5" })
1583
+ }
1584
+ ) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "h-11 w-11" }) })
1585
+ ] })
1586
+ ]
1587
+ }
1588
+ )
1589
+ ] }),
1590
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1591
+ OptionsGridPanel,
1592
+ {
1593
+ open: optionsOpen && !controlsHidden,
1594
+ onClose: () => setOptionsOpen(false),
1595
+ tiles
1596
+ }
1597
+ ),
1598
+ blocked && blockedSheet && !blockedDismissed && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1599
+ CaptureSheet,
1600
+ {
1601
+ open: true,
1602
+ onClose: () => setBlockedDismissed(true),
1603
+ body: blockedSheet.body,
1604
+ title: "Camera unavailable",
1605
+ actions: blockedSheet.actions
1606
+ }
1607
+ ),
1608
+ media && viewerKey !== null && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1609
+ MediaViewer,
1610
+ {
1611
+ items: media.items,
1612
+ initialKey: viewerKey,
1613
+ keysDisabled: editTarget !== null,
1614
+ onClose: () => setViewerKey(null),
1615
+ onDelete: media.onDelete ? (item) => {
1616
+ media.onDelete?.(item.key);
1617
+ invalidateMedia(item.key);
1618
+ } : void 0,
1619
+ onEdit: media.onReplacePhoto ? (item) => {
1620
+ const url = getMediaUrl(item);
1621
+ if (url) setEditTarget({ key: item.key, url });
1622
+ } : void 0
1623
+ }
1624
+ ),
1625
+ media?.onReplacePhoto && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1626
+ ImageEditSheet,
1627
+ {
1628
+ open: editTarget !== null,
1629
+ src: editTarget?.url ?? null,
1630
+ onClose: () => setEditTarget(null),
1631
+ onSave: (blob) => {
1632
+ const target = editTarget;
1633
+ setEditTarget(null);
1634
+ setViewerKey(null);
1635
+ if (target) {
1636
+ media.onReplacePhoto?.(target.key, blob);
1637
+ invalidateMedia(target.key);
1638
+ }
1639
+ }
1640
+ }
1641
+ ),
1642
+ slots.overlays
1643
+ ] });
1644
+ }
1645
+
1646
+ // src/components/CameraFeed.tsx
1647
+ var import_react6 = require("react");
1648
+ var import_jsx_runtime12 = require("react/jsx-runtime");
1649
+ function CameraFeed({
1650
+ engine,
1651
+ mirror = false
1652
+ }) {
1653
+ const { stream, videoRef } = engine;
1654
+ (0, import_react6.useEffect)(() => {
1655
+ const video = videoRef.current;
1656
+ if (!video) return;
1657
+ if (video.srcObject !== stream) video.srcObject = stream;
1658
+ return () => {
1659
+ video.srcObject = null;
1660
+ };
1661
+ }, [stream, videoRef]);
1662
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
1663
+ "video",
1664
+ {
1665
+ ref: videoRef,
1666
+ style: {
1667
+ width: "100%",
1668
+ height: "100%",
1669
+ objectFit: "cover",
1670
+ ...mirror ? { transform: "scaleX(-1)" } : {}
1671
+ },
1672
+ muted: true,
1673
+ autoPlay: true,
1674
+ playsInline: true
1675
+ }
1676
+ );
1677
+ }
1678
+
1679
+ // src/engine/useDefaultEngine.ts
1680
+ var import_react7 = require("react");
1681
+ var RECORDING_MIME_LADDER = [
1682
+ 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
1683
+ "video/mp4",
1684
+ 'video/webm;codecs="vp9,opus"',
1685
+ 'video/webm;codecs="vp8,opus"',
1686
+ "video/webm"
1687
+ ];
1688
+ function cropRect(w, h, aspect) {
1689
+ if (aspect === "full") return { x: 0, y: 0, w, h };
1690
+ const [aw, ah] = aspect === "1:1" ? [1, 1] : aspect === "4:3" ? [3, 4] : [9, 16];
1691
+ const [rw, rh] = w >= h ? [ah, aw] : [aw, ah];
1692
+ const target = rw / rh;
1693
+ let cw = w;
1694
+ let ch = w / target;
1695
+ if (ch > h) {
1696
+ ch = h;
1697
+ cw = h * target;
1698
+ }
1699
+ return { x: (w - cw) / 2, y: (h - ch) / 2, w: cw, h: ch };
1700
+ }
1701
+ function useDefaultCaptureEngine(options) {
1702
+ const {
1703
+ onPhoto,
1704
+ onVideo,
1705
+ onFiles,
1706
+ withAudio = true,
1707
+ facingMode: initialFacing = "environment",
1708
+ photoQuality = 0.92
1709
+ } = options;
1710
+ const [stream, setStream] = (0, import_react7.useState)(null);
1711
+ const [blocked, setBlocked] = (0, import_react7.useState)(null);
1712
+ const [facing, setFacing] = (0, import_react7.useState)(initialFacing);
1713
+ const [multipleCameras, setMultipleCameras] = (0, import_react7.useState)(false);
1714
+ const [recording, setRecording] = (0, import_react7.useState)(false);
1715
+ const [recordElapsedSeconds, setRecordElapsedSeconds] = (0, import_react7.useState)(0);
1716
+ const videoRef = (0, import_react7.useRef)(null);
1717
+ const streamRef = (0, import_react7.useRef)(null);
1718
+ const recorderRef = (0, import_react7.useRef)(null);
1719
+ const callbacksRef = (0, import_react7.useRef)({ onPhoto, onVideo, onFiles });
1720
+ callbacksRef.current = { onPhoto, onVideo, onFiles };
1721
+ (0, import_react7.useEffect)(() => {
1722
+ let cancelled = false;
1723
+ let acquired = null;
1724
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) {
1725
+ setBlocked({ reason: "not-supported" });
1726
+ return;
1727
+ }
1728
+ navigator.mediaDevices.getUserMedia({
1729
+ video: {
1730
+ facingMode: facing,
1731
+ width: { ideal: 4096 },
1732
+ height: { ideal: 4096 }
1733
+ },
1734
+ audio: false
1735
+ }).then((s) => {
1736
+ if (cancelled) {
1737
+ s.getTracks().forEach((t) => t.stop());
1738
+ return;
1739
+ }
1740
+ acquired = s;
1741
+ streamRef.current = s;
1742
+ setStream(s);
1743
+ setBlocked(null);
1744
+ return navigator.mediaDevices.enumerateDevices().then((devices) => {
1745
+ if (!cancelled)
1746
+ setMultipleCameras(
1747
+ devices.filter((d) => d.kind === "videoinput").length > 1
1748
+ );
1749
+ });
1750
+ }).catch((err) => {
1751
+ if (cancelled) return;
1752
+ const name = err && typeof err === "object" && "name" in err ? String(err.name) : "";
1753
+ setBlocked({
1754
+ reason: name === "NotAllowedError" || name === "SecurityError" ? "permission-denied" : "not-supported"
1755
+ });
1756
+ });
1757
+ return () => {
1758
+ cancelled = true;
1759
+ acquired?.getTracks().forEach((t) => t.stop());
1760
+ if (streamRef.current === acquired) streamRef.current = null;
1761
+ setStream(null);
1762
+ };
1763
+ }, [facing]);
1764
+ (0, import_react7.useEffect)(() => {
1765
+ return () => {
1766
+ const r = recorderRef.current;
1767
+ if (r) {
1768
+ clearInterval(r.timer);
1769
+ if (r.recorder.state !== "inactive") r.recorder.stop();
1770
+ r.micTracks.forEach((t) => t.stop());
1771
+ }
1772
+ };
1773
+ }, []);
1774
+ const onCapturePhoto = (0, import_react7.useCallback)(
1775
+ (opts) => {
1776
+ const video = videoRef.current;
1777
+ if (!video || video.videoWidth === 0) return;
1778
+ const rect = cropRect(
1779
+ video.videoWidth,
1780
+ video.videoHeight,
1781
+ opts?.aspect ?? "full"
1782
+ );
1783
+ const canvas = document.createElement("canvas");
1784
+ canvas.width = Math.round(rect.w);
1785
+ canvas.height = Math.round(rect.h);
1786
+ const ctx = canvas.getContext("2d");
1787
+ if (!ctx) return;
1788
+ ctx.drawImage(
1789
+ video,
1790
+ rect.x,
1791
+ rect.y,
1792
+ rect.w,
1793
+ rect.h,
1794
+ 0,
1795
+ 0,
1796
+ canvas.width,
1797
+ canvas.height
1798
+ );
1799
+ canvas.toBlob(
1800
+ (blob) => {
1801
+ if (!blob) return;
1802
+ callbacksRef.current.onPhoto(
1803
+ new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.jpg`, {
1804
+ type: "image/jpeg"
1805
+ })
1806
+ );
1807
+ },
1808
+ "image/jpeg",
1809
+ photoQuality
1810
+ );
1811
+ },
1812
+ [photoQuality]
1813
+ );
1814
+ const onStartRecording = (0, import_react7.useCallback)(() => {
1815
+ const base = streamRef.current;
1816
+ if (!base || recorderRef.current) return;
1817
+ void (async () => {
1818
+ let micTracks = [];
1819
+ if (withAudio) {
1820
+ try {
1821
+ const mic = await navigator.mediaDevices.getUserMedia({
1822
+ audio: true
1823
+ });
1824
+ micTracks = mic.getAudioTracks();
1825
+ } catch {
1826
+ }
1827
+ }
1828
+ const composed = new MediaStream([
1829
+ ...base.getVideoTracks(),
1830
+ ...micTracks
1831
+ ]);
1832
+ const mime = RECORDING_MIME_LADDER.find(
1833
+ (m) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m)
1834
+ );
1835
+ let recorder;
1836
+ try {
1837
+ recorder = new MediaRecorder(
1838
+ composed,
1839
+ mime ? { mimeType: mime } : void 0
1840
+ );
1841
+ } catch {
1842
+ micTracks.forEach((t) => t.stop());
1843
+ return;
1844
+ }
1845
+ const entry = {
1846
+ recorder,
1847
+ chunks: [],
1848
+ startedAt: performance.now(),
1849
+ micTracks,
1850
+ timer: setInterval(() => {
1851
+ setRecordElapsedSeconds(
1852
+ Math.floor((performance.now() - entry.startedAt) / 1e3)
1853
+ );
1854
+ }, 250)
1855
+ };
1856
+ recorderRef.current = entry;
1857
+ recorder.ondataavailable = (e) => {
1858
+ if (e.data.size > 0) entry.chunks.push(e.data);
1859
+ };
1860
+ recorder.onstop = () => {
1861
+ clearInterval(entry.timer);
1862
+ entry.micTracks.forEach((t) => t.stop());
1863
+ recorderRef.current = null;
1864
+ setRecording(false);
1865
+ const durationMs = Math.round(performance.now() - entry.startedAt);
1866
+ const type = recorder.mimeType || entry.chunks[0]?.type || "video/webm";
1867
+ const blob = new Blob(entry.chunks, { type });
1868
+ const ext = type.includes("mp4") ? "mp4" : "webm";
1869
+ callbacksRef.current.onVideo(
1870
+ new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.${ext}`, {
1871
+ type
1872
+ }),
1873
+ durationMs
1874
+ );
1875
+ };
1876
+ recorder.start(1e3);
1877
+ setRecordElapsedSeconds(0);
1878
+ setRecording(true);
1879
+ })();
1880
+ }, [withAudio]);
1881
+ const onStopRecording = (0, import_react7.useCallback)(() => {
1882
+ const r = recorderRef.current;
1883
+ if (r && r.recorder.state !== "inactive") r.recorder.stop();
1884
+ }, []);
1885
+ const onUpload = (0, import_react7.useCallback)(() => {
1886
+ const input = document.createElement("input");
1887
+ input.type = "file";
1888
+ input.accept = "image/*,video/*";
1889
+ input.multiple = true;
1890
+ input.onchange = () => {
1891
+ if (input.files && input.files.length > 0)
1892
+ callbacksRef.current.onFiles(input.files);
1893
+ };
1894
+ input.click();
1895
+ }, []);
1896
+ const onFlipCamera = (0, import_react7.useCallback)(() => {
1897
+ setFacing((f) => f === "environment" ? "user" : "environment");
1898
+ }, []);
1899
+ return (0, import_react7.useMemo)(
1900
+ () => ({
1901
+ stream,
1902
+ videoRef,
1903
+ blocked,
1904
+ onCapturePhoto,
1905
+ onStartRecording,
1906
+ onStopRecording,
1907
+ recording,
1908
+ recordElapsedSeconds,
1909
+ onUpload,
1910
+ onFlipCamera: multipleCameras ? onFlipCamera : null
1911
+ }),
1912
+ [
1913
+ stream,
1914
+ blocked,
1915
+ onCapturePhoto,
1916
+ onStartRecording,
1917
+ onStopRecording,
1918
+ recording,
1919
+ recordElapsedSeconds,
1920
+ onUpload,
1921
+ onFlipCamera,
1922
+ multipleCameras
1923
+ ]
1924
+ );
1925
+ }
1003
1926
  //# sourceMappingURL=react.cjs.map