@ai-matrx/capture 0.1.1 → 0.2.0

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