@ai-matrx/capture 0.4.3 → 0.5.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.cjs CHANGED
@@ -28,6 +28,7 @@ __export(react_exports, {
28
28
  CaptureFilmstrip: () => CaptureFilmstrip,
29
29
  CaptureRail: () => CaptureRail,
30
30
  CaptureSheet: () => CaptureSheet,
31
+ CloudLibrarySheet: () => CloudLibrarySheet,
31
32
  CountdownOverlay: () => CountdownOverlay,
32
33
  GridOverlay: () => GridOverlay,
33
34
  HoldShutter: () => HoldShutter,
@@ -35,14 +36,33 @@ __export(react_exports, {
35
36
  MediaViewer: () => MediaViewer,
36
37
  ModeSelector: () => ModeSelector,
37
38
  OptionsGridPanel: () => OptionsGridPanel,
39
+ PHOTO_JPEG_QUALITY: () => PHOTO_JPEG_QUALITY,
38
40
  ShutterButton: () => ShutterButton,
39
41
  ZoomRow: () => ZoomRow,
42
+ acquireWarmMic: () => acquireWarmMic,
43
+ adoptWarmAudioStream: () => adoptWarmAudioStream,
44
+ buildWarmMicConstraints: () => buildWarmMicConstraints,
45
+ classifyCameraBlockReason: () => classifyCameraBlockReason,
46
+ cropBlobToAspect: () => cropBlobToAspect,
40
47
  getMediaUrl: () => getMediaUrl,
48
+ getWarmMicState: () => getWarmMicState,
49
+ hardStopWarmMic: () => hardStopWarmMic,
41
50
  invalidateMedia: () => invalidateMedia,
51
+ isMediaDenialError: () => isMediaDenialError,
52
+ nextCameraDevice: () => nextCameraDevice,
53
+ notifyMicPermissionRevoked: () => notifyMicPermissionRevoked,
42
54
  primeMedia: () => primeMedia,
55
+ queryCameraPermission: () => queryCameraPermission,
56
+ queryMicPermission: () => queryMicPermission,
57
+ releaseWarmMic: () => releaseWarmMic,
58
+ setPreferredMicDeviceId: () => setPreferredMicDeviceId,
59
+ shouldCombineMicPrompt: () => shouldCombineMicPrompt,
60
+ subscribeWarmMic: () => subscribeWarmMic,
61
+ subscribeWarmMicInterruption: () => subscribeWarmMicInterruption,
43
62
  useDefaultCaptureEngine: () => useDefaultCaptureEngine,
44
63
  useMediaUrl: () => useMediaUrl,
45
- useTrackControls: () => useTrackControls
64
+ useTrackControls: () => useTrackControls,
65
+ warmMicDebug: () => warmMicDebug
46
66
  });
47
67
  module.exports = __toCommonJS(react_exports);
48
68
 
@@ -118,6 +138,23 @@ function ImagesIcon(props) {
118
138
  function Loader2Icon(props) {
119
139
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { ...svgProps(props), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" }) });
120
140
  }
141
+ function UploadIcon(props) {
142
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { ...svgProps(props), children: [
143
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M12 3v12" }),
144
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "m17 8-5-5-5 5" }),
145
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" })
146
+ ] });
147
+ }
148
+ function ImageOffIcon(props) {
149
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { ...svgProps(props), children: [
150
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "2", x2: "22", y1: "2", y2: "22" }),
151
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M10.41 10.41a2 2 0 1 1-2.83-2.83" }),
152
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "13.5", x2: "6", y1: "13.5", y2: "21" }),
153
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("line", { x1: "18", x2: "21", y1: "12", y2: "15" }),
154
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59" }),
155
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M21 15V5a2 2 0 0 0-2-2H9" })
156
+ ] });
157
+ }
121
158
  function LockIcon(props) {
122
159
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { ...svgProps(props), children: [
123
160
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { width: "18", height: "11", x: "3", y: "11", rx: "2", ry: "2" }),
@@ -2572,6 +2609,349 @@ function CameraFeed({
2572
2609
 
2573
2610
  // src/engine/useDefaultEngine.ts
2574
2611
  var import_react11 = require("react");
2612
+
2613
+ // src/engine/crop.ts
2614
+ var PHOTO_JPEG_QUALITY = 0.92;
2615
+ async function cropBlobToAspect(blob, aspect, quality = PHOTO_JPEG_QUALITY) {
2616
+ if (aspect === "full") return blob;
2617
+ const [wRatio, hRatio] = aspect === "1:1" ? [1, 1] : aspect === "4:3" ? [4, 3] : [16, 9];
2618
+ const bitmap = await createImageBitmap(blob);
2619
+ try {
2620
+ const srcW = bitmap.width;
2621
+ const srcH = bitmap.height;
2622
+ const target = srcW >= srcH ? wRatio / hRatio : hRatio / wRatio;
2623
+ let cropW = srcW;
2624
+ let cropH = Math.round(srcW / target);
2625
+ if (cropH > srcH) {
2626
+ cropH = srcH;
2627
+ cropW = Math.round(srcH * target);
2628
+ }
2629
+ if (cropW === srcW && cropH === srcH) return blob;
2630
+ const canvas = document.createElement("canvas");
2631
+ canvas.width = cropW;
2632
+ canvas.height = cropH;
2633
+ const ctx = canvas.getContext("2d");
2634
+ if (!ctx) return blob;
2635
+ ctx.drawImage(
2636
+ bitmap,
2637
+ Math.round((srcW - cropW) / 2),
2638
+ Math.round((srcH - cropH) / 2),
2639
+ cropW,
2640
+ cropH,
2641
+ 0,
2642
+ 0,
2643
+ cropW,
2644
+ cropH
2645
+ );
2646
+ return await new Promise(
2647
+ (resolve) => canvas.toBlob((out) => resolve(out ?? blob), "image/jpeg", quality)
2648
+ );
2649
+ } finally {
2650
+ bitmap.close();
2651
+ }
2652
+ }
2653
+ function nextCameraDevice(cameras, currentDeviceId) {
2654
+ if (cameras.length < 2) return null;
2655
+ const currentIdx = currentDeviceId ? cameras.findIndex((c) => c.deviceId === currentDeviceId) : -1;
2656
+ return cameras[(Math.max(currentIdx, 0) + 1) % cameras.length] ?? null;
2657
+ }
2658
+
2659
+ // src/engine/permissions.ts
2660
+ async function queryPermission(name) {
2661
+ if (typeof navigator === "undefined" || !navigator.permissions) {
2662
+ return "unknown";
2663
+ }
2664
+ try {
2665
+ const status = await navigator.permissions.query({
2666
+ name
2667
+ });
2668
+ if (status.state === "granted") return "granted";
2669
+ if (status.state === "denied") return "denied";
2670
+ return "prompt";
2671
+ } catch {
2672
+ return "unknown";
2673
+ }
2674
+ }
2675
+ function queryCameraPermission() {
2676
+ return queryPermission("camera");
2677
+ }
2678
+ function queryMicPermission() {
2679
+ return queryPermission("microphone");
2680
+ }
2681
+ function shouldCombineMicPrompt(micPermissionState) {
2682
+ return micPermissionState === "prompt" || micPermissionState === "unknown";
2683
+ }
2684
+ function isMediaDenialError(err) {
2685
+ const name = err && typeof err === "object" && "name" in err ? String(err.name) : "";
2686
+ return name === "NotAllowedError" || name === "SecurityError";
2687
+ }
2688
+ function classifyCameraBlockReason(err) {
2689
+ return isMediaDenialError(err) ? "permission-denied" : "not-supported";
2690
+ }
2691
+
2692
+ // src/engine/warm-mic.ts
2693
+ var DEFAULT_KEEPALIVE_MS = 6e3;
2694
+ var STATE_SLOT2 = /* @__PURE__ */ Symbol.for("ai-matrx.capture.warm-mic-state");
2695
+ function getManager() {
2696
+ const holder = globalThis;
2697
+ let m = holder[STATE_SLOT2];
2698
+ if (!m) {
2699
+ m = {
2700
+ stream: null,
2701
+ inFlight: null,
2702
+ refCount: 0,
2703
+ releaseTimer: null,
2704
+ state: "idle",
2705
+ keepAliveMs: DEFAULT_KEEPALIVE_MS,
2706
+ preferredInputDeviceId: null,
2707
+ listeners: /* @__PURE__ */ new Set(),
2708
+ interruptionListeners: /* @__PURE__ */ new Set(),
2709
+ pageLifecycleWatched: false,
2710
+ permissionWatched: false
2711
+ };
2712
+ holder[STATE_SLOT2] = m;
2713
+ }
2714
+ return m;
2715
+ }
2716
+ function emitInterruption(reason) {
2717
+ for (const l of getManager().interruptionListeners) {
2718
+ try {
2719
+ l(reason);
2720
+ } catch {
2721
+ }
2722
+ }
2723
+ }
2724
+ function subscribeWarmMicInterruption(listener) {
2725
+ const m = getManager();
2726
+ m.interruptionListeners.add(listener);
2727
+ return () => {
2728
+ m.interruptionListeners.delete(listener);
2729
+ };
2730
+ }
2731
+ function setState(next) {
2732
+ const m = getManager();
2733
+ m.state = next;
2734
+ for (const l of m.listeners) {
2735
+ try {
2736
+ l(next);
2737
+ } catch {
2738
+ }
2739
+ }
2740
+ }
2741
+ function clearReleaseTimer() {
2742
+ const m = getManager();
2743
+ if (m.releaseTimer) {
2744
+ clearTimeout(m.releaseTimer);
2745
+ m.releaseTimer = null;
2746
+ }
2747
+ }
2748
+ function attachTrackHealth(stream) {
2749
+ const m = getManager();
2750
+ for (const track of stream.getAudioTracks()) {
2751
+ track.onended = () => {
2752
+ console.error(
2753
+ "[capture:warm-mic] mic track ENDED (OS interruption / device change). The warm grant is gone; the next recording will re-prompt."
2754
+ );
2755
+ if (m.stream === stream) {
2756
+ m.stream = null;
2757
+ setState("error");
2758
+ }
2759
+ emitInterruption("ended");
2760
+ };
2761
+ track.onmute = () => {
2762
+ console.warn(
2763
+ "[capture:warm-mic] mic track MUTED (transient interruption \u2014 e.g. a call). The grant survives; it should unmute when the interruption ends."
2764
+ );
2765
+ emitInterruption("muted");
2766
+ };
2767
+ track.onunmute = () => {
2768
+ emitInterruption("unmuted");
2769
+ };
2770
+ }
2771
+ }
2772
+ function notifyMicPermissionRevoked() {
2773
+ console.error("[capture:warm-mic] microphone permission REVOKED.");
2774
+ hardStopWarmMic();
2775
+ emitInterruption("permission-revoked");
2776
+ }
2777
+ function watchMicPermission() {
2778
+ const m = getManager();
2779
+ if (m.permissionWatched) return;
2780
+ if (typeof navigator === "undefined" || !navigator.permissions) return;
2781
+ m.permissionWatched = true;
2782
+ void (async () => {
2783
+ try {
2784
+ const status = await navigator.permissions.query({
2785
+ name: "microphone"
2786
+ });
2787
+ status.onchange = () => {
2788
+ if (status.state === "denied") notifyMicPermissionRevoked();
2789
+ };
2790
+ } catch {
2791
+ }
2792
+ })();
2793
+ }
2794
+ function watchPageLifecycle() {
2795
+ const m = getManager();
2796
+ if (m.pageLifecycleWatched) return;
2797
+ if (typeof window === "undefined") return;
2798
+ m.pageLifecycleWatched = true;
2799
+ window.addEventListener("pagehide", () => {
2800
+ if (m.stream || m.refCount > 0) {
2801
+ if (m.refCount > 0) {
2802
+ console.error(
2803
+ `[capture:warm-mic] page hiding with ${m.refCount} unreleased mic holder(s) \u2014 forcing release. A surface leaked acquireWarmMic without a matching releaseWarmMic on unmount.`
2804
+ );
2805
+ }
2806
+ hardStopWarmMic();
2807
+ }
2808
+ });
2809
+ }
2810
+ function setPreferredMicDeviceId(id) {
2811
+ getManager().preferredInputDeviceId = id;
2812
+ }
2813
+ function streamIsLive(stream) {
2814
+ if (!stream) return false;
2815
+ const tracks = stream.getAudioTracks();
2816
+ return tracks.length > 0 && tracks.every((t) => t.readyState === "live");
2817
+ }
2818
+ function buildWarmMicConstraints() {
2819
+ const m = getManager();
2820
+ const audio = {
2821
+ echoCancellation: true,
2822
+ noiseSuppression: true,
2823
+ autoGainControl: true
2824
+ };
2825
+ if (m.preferredInputDeviceId) {
2826
+ audio.deviceId = { ideal: m.preferredInputDeviceId };
2827
+ }
2828
+ return audio;
2829
+ }
2830
+ function adoptWarmAudioStream(stream) {
2831
+ const m = getManager();
2832
+ if (streamIsLive(m.stream) || m.inFlight) {
2833
+ for (const t of stream.getTracks()) {
2834
+ try {
2835
+ t.stop();
2836
+ } catch {
2837
+ }
2838
+ }
2839
+ return;
2840
+ }
2841
+ if (!streamIsLive(stream)) return;
2842
+ clearReleaseTimer();
2843
+ m.stream = stream;
2844
+ attachTrackHealth(stream);
2845
+ watchPageLifecycle();
2846
+ watchMicPermission();
2847
+ if (m.refCount > 0) {
2848
+ setState("active");
2849
+ } else {
2850
+ armKeepaliveStop();
2851
+ }
2852
+ }
2853
+ async function acquireWarmMic(constraints) {
2854
+ const m = getManager();
2855
+ clearReleaseTimer();
2856
+ m.refCount += 1;
2857
+ if (streamIsLive(m.stream)) {
2858
+ setState("active");
2859
+ return m.stream;
2860
+ }
2861
+ m.stream = null;
2862
+ if (m.inFlight) {
2863
+ return m.inFlight;
2864
+ }
2865
+ setState("acquiring");
2866
+ const audio = constraints ?? buildWarmMicConstraints();
2867
+ if (m.preferredInputDeviceId && audio.deviceId == null) {
2868
+ audio.deviceId = { ideal: m.preferredInputDeviceId };
2869
+ }
2870
+ m.inFlight = (async () => {
2871
+ try {
2872
+ const stream = await navigator.mediaDevices.getUserMedia({ audio });
2873
+ m.stream = stream;
2874
+ attachTrackHealth(stream);
2875
+ watchPageLifecycle();
2876
+ watchMicPermission();
2877
+ if (m.refCount === 0) {
2878
+ armKeepaliveStop();
2879
+ return stream;
2880
+ }
2881
+ setState("active");
2882
+ return stream;
2883
+ } catch (err) {
2884
+ m.refCount = Math.max(0, m.refCount - 1);
2885
+ setState("error");
2886
+ throw err;
2887
+ } finally {
2888
+ m.inFlight = null;
2889
+ }
2890
+ })();
2891
+ return m.inFlight;
2892
+ }
2893
+ function releaseWarmMic() {
2894
+ const m = getManager();
2895
+ if (m.refCount === 0) {
2896
+ console.error(
2897
+ "[capture:warm-mic] releaseWarmMic() with zero holders \u2014 unbalanced release. Some caller released twice (or released a hold it never acquired)."
2898
+ );
2899
+ return;
2900
+ }
2901
+ m.refCount -= 1;
2902
+ if (m.refCount > 0) return;
2903
+ if (!m.stream) {
2904
+ setState(m.inFlight ? "keepalive" : "idle");
2905
+ return;
2906
+ }
2907
+ armKeepaliveStop();
2908
+ }
2909
+ function armKeepaliveStop() {
2910
+ const m = getManager();
2911
+ clearReleaseTimer();
2912
+ setState("keepalive");
2913
+ m.releaseTimer = setTimeout(() => {
2914
+ m.releaseTimer = null;
2915
+ if (m.refCount > 0) return;
2916
+ hardStopWarmMic();
2917
+ }, m.keepAliveMs);
2918
+ }
2919
+ function hardStopWarmMic() {
2920
+ const m = getManager();
2921
+ clearReleaseTimer();
2922
+ if (m.stream) {
2923
+ for (const t of m.stream.getTracks()) {
2924
+ try {
2925
+ t.stop();
2926
+ } catch {
2927
+ }
2928
+ }
2929
+ m.stream = null;
2930
+ }
2931
+ m.refCount = 0;
2932
+ setState("idle");
2933
+ }
2934
+ function getWarmMicState() {
2935
+ return getManager().state;
2936
+ }
2937
+ function subscribeWarmMic(listener) {
2938
+ const m = getManager();
2939
+ m.listeners.add(listener);
2940
+ return () => {
2941
+ m.listeners.delete(listener);
2942
+ };
2943
+ }
2944
+ function warmMicDebug() {
2945
+ const m = getManager();
2946
+ return {
2947
+ state: m.state,
2948
+ refCount: m.refCount,
2949
+ live: streamIsLive(m.stream),
2950
+ keepAliveMs: m.keepAliveMs
2951
+ };
2952
+ }
2953
+
2954
+ // src/engine/useDefaultEngine.ts
2575
2955
  var RECORDING_MIME_LADDER = [
2576
2956
  'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
2577
2957
  "video/mp4",
@@ -2579,39 +2959,38 @@ var RECORDING_MIME_LADDER = [
2579
2959
  'video/webm;codecs="vp8,opus"',
2580
2960
  "video/webm"
2581
2961
  ];
2582
- function cropRect(w, h, aspect) {
2583
- if (aspect === "full") return { x: 0, y: 0, w, h };
2584
- const [aw, ah] = aspect === "1:1" ? [1, 1] : aspect === "4:3" ? [3, 4] : [9, 16];
2585
- const [rw, rh] = w >= h ? [ah, aw] : [aw, ah];
2586
- const target = rw / rh;
2587
- let cw = w;
2588
- let ch = w / target;
2589
- if (ch > h) {
2590
- ch = h;
2591
- cw = h * target;
2592
- }
2593
- return { x: (w - cw) / 2, y: (h - ch) / 2, w: cw, h: ch };
2594
- }
2595
2962
  function useDefaultCaptureEngine(options) {
2596
2963
  const {
2597
2964
  onPhoto,
2598
2965
  onVideo,
2599
2966
  onFiles,
2600
2967
  withAudio = true,
2968
+ mode,
2601
2969
  facingMode: initialFacing = "environment",
2602
- photoQuality = 0.92
2970
+ photoQuality = 0.92,
2971
+ fileNamePrefix = "capture",
2972
+ onError
2603
2973
  } = options;
2604
2974
  const [stream, setStream] = (0, import_react11.useState)(null);
2605
2975
  const [blocked, setBlocked] = (0, import_react11.useState)(null);
2606
- const [facing, setFacing] = (0, import_react11.useState)(initialFacing);
2607
- const [multipleCameras, setMultipleCameras] = (0, import_react11.useState)(false);
2976
+ const [deviceId, setDeviceId] = (0, import_react11.useState)(null);
2977
+ const [cameras, setCameras] = (0, import_react11.useState)([]);
2608
2978
  const [recording, setRecording] = (0, import_react11.useState)(false);
2609
2979
  const [recordElapsedSeconds, setRecordElapsedSeconds] = (0, import_react11.useState)(0);
2980
+ const [flash, setFlash] = (0, import_react11.useState)(false);
2981
+ const [acquireToken, setAcquireToken] = (0, import_react11.useState)(0);
2610
2982
  const videoRef = (0, import_react11.useRef)(null);
2611
2983
  const streamRef = (0, import_react11.useRef)(null);
2612
2984
  const recorderRef = (0, import_react11.useRef)(null);
2613
- const callbacksRef = (0, import_react11.useRef)({ onPhoto, onVideo, onFiles });
2614
- callbacksRef.current = { onPhoto, onVideo, onFiles };
2985
+ const captureBusyRef = (0, import_react11.useRef)(false);
2986
+ const reacquiredOnceRef = (0, import_react11.useRef)(false);
2987
+ const callbacksRef = (0, import_react11.useRef)({ onPhoto, onVideo, onFiles, onError });
2988
+ callbacksRef.current = { onPhoto, onVideo, onFiles, onError };
2989
+ const reportError = (0, import_react11.useCallback)((message, err) => {
2990
+ const sink = callbacksRef.current.onError;
2991
+ if (sink) sink(message, err);
2992
+ else console.error(`[capture:engine] ${message}`, err);
2993
+ }, []);
2615
2994
  (0, import_react11.useEffect)(() => {
2616
2995
  let cancelled = false;
2617
2996
  let acquired = null;
@@ -2619,110 +2998,176 @@ function useDefaultCaptureEngine(options) {
2619
2998
  setBlocked({ reason: "not-supported" });
2620
2999
  return;
2621
3000
  }
2622
- navigator.mediaDevices.getUserMedia({
2623
- video: {
2624
- facingMode: facing,
2625
- width: { ideal: 4096 },
2626
- height: { ideal: 4096 }
2627
- },
2628
- audio: false
2629
- }).then((s) => {
2630
- if (cancelled) {
2631
- s.getTracks().forEach((t) => t.stop());
3001
+ void (async () => {
3002
+ const known = await queryCameraPermission();
3003
+ if (cancelled) return;
3004
+ if (known === "denied") {
3005
+ setBlocked({ reason: "permission-denied" });
2632
3006
  return;
2633
3007
  }
2634
- acquired = s;
2635
- streamRef.current = s;
2636
- setStream(s);
2637
- setBlocked(null);
2638
- return navigator.mediaDevices.enumerateDevices().then((devices) => {
2639
- if (!cancelled)
2640
- setMultipleCameras(
2641
- devices.filter((d) => d.kind === "videoinput").length > 1
2642
- );
2643
- });
2644
- }).catch((err) => {
3008
+ const includeMic = withAudio && shouldCombineMicPrompt(await queryMicPermission());
2645
3009
  if (cancelled) return;
2646
- const name = err && typeof err === "object" && "name" in err ? String(err.name) : "";
2647
- setBlocked({
2648
- reason: name === "NotAllowedError" || name === "SecurityError" ? "permission-denied" : "not-supported"
2649
- });
2650
- });
3010
+ const video = {
3011
+ width: { ideal: 4096 },
3012
+ height: { ideal: 4096 },
3013
+ ...deviceId ? { deviceId: { exact: deviceId } } : { facingMode: initialFacing }
3014
+ };
3015
+ const acquire = async (mic) => {
3016
+ try {
3017
+ return await navigator.mediaDevices.getUserMedia(
3018
+ mic ? { video, audio: buildWarmMicConstraints() } : { video, audio: false }
3019
+ );
3020
+ } catch (err) {
3021
+ if (mic && classifyCameraBlockReason(err) === "permission-denied") {
3022
+ return navigator.mediaDevices.getUserMedia({ video, audio: false });
3023
+ }
3024
+ throw err;
3025
+ }
3026
+ };
3027
+ try {
3028
+ const s = await acquire(includeMic);
3029
+ if (cancelled) {
3030
+ s.getTracks().forEach((t) => t.stop());
3031
+ return;
3032
+ }
3033
+ const audioTracks = s.getAudioTracks();
3034
+ if (audioTracks.length > 0) {
3035
+ for (const t of audioTracks) s.removeTrack(t);
3036
+ adoptWarmAudioStream(new MediaStream(audioTracks));
3037
+ }
3038
+ acquired = s;
3039
+ streamRef.current = s;
3040
+ setStream(s);
3041
+ setBlocked(null);
3042
+ reacquiredOnceRef.current = false;
3043
+ for (const track of s.getVideoTracks()) {
3044
+ track.onended = () => {
3045
+ if (cancelled || streamRef.current !== s) return;
3046
+ console.error(
3047
+ "[capture:engine] camera track ENDED (OS interruption / device change) \u2014 reacquiring."
3048
+ );
3049
+ if (reacquiredOnceRef.current) {
3050
+ setBlocked({ reason: "not-supported" });
3051
+ return;
3052
+ }
3053
+ reacquiredOnceRef.current = true;
3054
+ setAcquireToken((n) => n + 1);
3055
+ };
3056
+ }
3057
+ const devices = await navigator.mediaDevices.enumerateDevices();
3058
+ if (!cancelled) {
3059
+ setCameras(devices.filter((d) => d.kind === "videoinput"));
3060
+ }
3061
+ } catch (err) {
3062
+ if (cancelled) return;
3063
+ setBlocked({ reason: classifyCameraBlockReason(err) });
3064
+ }
3065
+ })();
2651
3066
  return () => {
2652
3067
  cancelled = true;
2653
3068
  acquired?.getTracks().forEach((t) => t.stop());
2654
3069
  if (streamRef.current === acquired) streamRef.current = null;
2655
3070
  setStream(null);
2656
3071
  };
2657
- }, [facing]);
3072
+ }, [deviceId, initialFacing, withAudio, acquireToken]);
3073
+ const cameraBlocked = blocked !== null;
3074
+ (0, import_react11.useEffect)(() => {
3075
+ if (mode !== "video" || cameraBlocked || !withAudio) return;
3076
+ let cancelled = false;
3077
+ let held = false;
3078
+ void (async () => {
3079
+ if (await queryMicPermission() === "denied") return;
3080
+ if (cancelled) return;
3081
+ acquireWarmMic().then(() => {
3082
+ if (cancelled) {
3083
+ releaseWarmMic();
3084
+ return;
3085
+ }
3086
+ held = true;
3087
+ }).catch(() => {
3088
+ });
3089
+ })();
3090
+ return () => {
3091
+ cancelled = true;
3092
+ if (held) releaseWarmMic();
3093
+ };
3094
+ }, [mode, cameraBlocked, withAudio]);
2658
3095
  (0, import_react11.useEffect)(() => {
2659
3096
  return () => {
2660
3097
  const r = recorderRef.current;
2661
3098
  if (r) {
2662
3099
  clearInterval(r.timer);
2663
3100
  if (r.recorder.state !== "inactive") r.recorder.stop();
2664
- r.micTracks.forEach((t) => t.stop());
2665
3101
  }
2666
3102
  };
2667
3103
  }, []);
2668
- const onCapturePhoto = (0, import_react11.useCallback)(
3104
+ const capturePhotoWith = (0, import_react11.useCallback)(
2669
3105
  (opts) => {
2670
3106
  const video = videoRef.current;
2671
3107
  if (!video || video.videoWidth === 0) return;
2672
- const rect = cropRect(
2673
- video.videoWidth,
2674
- video.videoHeight,
2675
- opts?.aspect ?? "full"
2676
- );
2677
- const canvas = document.createElement("canvas");
2678
- canvas.width = Math.round(rect.w);
2679
- canvas.height = Math.round(rect.h);
2680
- const ctx = canvas.getContext("2d");
2681
- if (!ctx) return;
2682
- ctx.drawImage(
2683
- video,
2684
- rect.x,
2685
- rect.y,
2686
- rect.w,
2687
- rect.h,
2688
- 0,
2689
- 0,
2690
- canvas.width,
2691
- canvas.height
2692
- );
2693
- canvas.toBlob(
2694
- (blob) => {
2695
- if (!blob) return;
3108
+ if (captureBusyRef.current) return;
3109
+ captureBusyRef.current = true;
3110
+ void (async () => {
3111
+ try {
3112
+ const canvas = document.createElement("canvas");
3113
+ canvas.width = video.videoWidth;
3114
+ canvas.height = video.videoHeight;
3115
+ const ctx = canvas.getContext("2d");
3116
+ if (!ctx) throw new Error("2d canvas context unavailable");
3117
+ ctx.drawImage(video, 0, 0);
3118
+ const full = await new Promise(
3119
+ (resolve, reject) => canvas.toBlob(
3120
+ (b) => b ? resolve(b) : reject(new Error("toBlob returned null")),
3121
+ "image/jpeg",
3122
+ photoQuality
3123
+ )
3124
+ );
3125
+ const blob = await cropBlobToAspect(
3126
+ full,
3127
+ opts.aspect ?? "full",
3128
+ photoQuality
3129
+ );
2696
3130
  callbacksRef.current.onPhoto(
2697
- new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.jpg`, {
2698
- type: "image/jpeg"
2699
- })
3131
+ new File(
3132
+ [blob],
3133
+ `${opts.fileNamePrefix}-${(/* @__PURE__ */ new Date()).toISOString()}.jpg`,
3134
+ { type: "image/jpeg" }
3135
+ )
2700
3136
  );
2701
- },
2702
- "image/jpeg",
2703
- photoQuality
3137
+ setFlash(true);
3138
+ window.setTimeout(() => setFlash(false), 120);
3139
+ } catch (err) {
3140
+ reportError("The photo could not be captured \u2014 try again.", err);
3141
+ } finally {
3142
+ captureBusyRef.current = false;
3143
+ }
3144
+ })();
3145
+ },
3146
+ [photoQuality, reportError]
3147
+ );
3148
+ const onCapturePhoto = (0, import_react11.useCallback)(
3149
+ (opts) => {
3150
+ capturePhotoWith(
3151
+ opts?.aspect ? { fileNamePrefix, aspect: opts.aspect } : { fileNamePrefix }
2704
3152
  );
2705
3153
  },
2706
- [photoQuality]
3154
+ [capturePhotoWith, fileNamePrefix]
2707
3155
  );
2708
3156
  const onStartRecording = (0, import_react11.useCallback)(() => {
2709
3157
  const base = streamRef.current;
2710
3158
  if (!base || recorderRef.current) return;
2711
3159
  void (async () => {
2712
- let micTracks = [];
3160
+ let micClones = [];
3161
+ let micHeld = false;
2713
3162
  if (withAudio) {
2714
3163
  try {
2715
- const mic = await navigator.mediaDevices.getUserMedia({
2716
- audio: true
2717
- });
2718
- micTracks = mic.getAudioTracks();
3164
+ const mic = await acquireWarmMic();
3165
+ micHeld = true;
3166
+ micClones = mic.getAudioTracks().map((t) => t.clone());
2719
3167
  } catch {
2720
3168
  }
2721
3169
  }
2722
- const composed = new MediaStream([
2723
- ...base.getVideoTracks(),
2724
- ...micTracks
2725
- ]);
3170
+ const composed = new MediaStream([...base.getVideoTracks(), ...micClones]);
2726
3171
  const mime = RECORDING_MIME_LADDER.find(
2727
3172
  (m) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m)
2728
3173
  );
@@ -2732,15 +3177,18 @@ function useDefaultCaptureEngine(options) {
2732
3177
  composed,
2733
3178
  mime ? { mimeType: mime } : void 0
2734
3179
  );
2735
- } catch {
2736
- micTracks.forEach((t) => t.stop());
3180
+ } catch (err) {
3181
+ micClones.forEach((t) => t.stop());
3182
+ if (micHeld) releaseWarmMic();
3183
+ reportError("Could not start the video recording.", err);
2737
3184
  return;
2738
3185
  }
2739
3186
  const entry = {
2740
3187
  recorder,
2741
3188
  chunks: [],
2742
3189
  startedAt: performance.now(),
2743
- micTracks,
3190
+ micClones,
3191
+ micHeld,
2744
3192
  timer: setInterval(() => {
2745
3193
  setRecordElapsedSeconds(
2746
3194
  Math.floor((performance.now() - entry.startedAt) / 1e3)
@@ -2753,7 +3201,8 @@ function useDefaultCaptureEngine(options) {
2753
3201
  };
2754
3202
  recorder.onstop = () => {
2755
3203
  clearInterval(entry.timer);
2756
- entry.micTracks.forEach((t) => t.stop());
3204
+ entry.micClones.forEach((t) => t.stop());
3205
+ if (entry.micHeld) releaseWarmMic();
2757
3206
  recorderRef.current = null;
2758
3207
  setRecording(false);
2759
3208
  const durationMs = Math.round(performance.now() - entry.startedAt);
@@ -2761,7 +3210,7 @@ function useDefaultCaptureEngine(options) {
2761
3210
  const blob = new Blob(entry.chunks, { type });
2762
3211
  const ext = type.includes("mp4") ? "mp4" : "webm";
2763
3212
  callbacksRef.current.onVideo(
2764
- new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.${ext}`, {
3213
+ new File([blob], `${fileNamePrefix}-video-${Date.now()}.${ext}`, {
2765
3214
  type
2766
3215
  }),
2767
3216
  durationMs
@@ -2771,7 +3220,7 @@ function useDefaultCaptureEngine(options) {
2771
3220
  setRecordElapsedSeconds(0);
2772
3221
  setRecording(true);
2773
3222
  })();
2774
- }, [withAudio]);
3223
+ }, [withAudio, fileNamePrefix, reportError]);
2775
3224
  const onStopRecording = (0, import_react11.useCallback)(() => {
2776
3225
  const r = recorderRef.current;
2777
3226
  if (r && r.recorder.state !== "inactive") r.recorder.stop();
@@ -2788,8 +3237,12 @@ function useDefaultCaptureEngine(options) {
2788
3237
  input.click();
2789
3238
  }, []);
2790
3239
  const onFlipCamera = (0, import_react11.useCallback)(() => {
2791
- setFacing((f) => f === "environment" ? "user" : "environment");
2792
- }, []);
3240
+ if (recorderRef.current) return;
3241
+ const current = deviceId ?? streamRef.current?.getVideoTracks()[0]?.getSettings().deviceId ?? null;
3242
+ const next = nextCameraDevice(cameras, current);
3243
+ if (next) setDeviceId(next.deviceId);
3244
+ }, [cameras, deviceId]);
3245
+ const canFlip = cameras.length > 1 && !cameraBlocked && !recording;
2793
3246
  return (0, import_react11.useMemo)(
2794
3247
  () => ({
2795
3248
  stream,
@@ -2801,7 +3254,9 @@ function useDefaultCaptureEngine(options) {
2801
3254
  recording,
2802
3255
  recordElapsedSeconds,
2803
3256
  onUpload,
2804
- onFlipCamera: multipleCameras ? onFlipCamera : null
3257
+ onFlipCamera: canFlip ? onFlipCamera : null,
3258
+ flash,
3259
+ capturePhotoWith
2805
3260
  }),
2806
3261
  [
2807
3262
  stream,
@@ -2813,8 +3268,129 @@ function useDefaultCaptureEngine(options) {
2813
3268
  recordElapsedSeconds,
2814
3269
  onUpload,
2815
3270
  onFlipCamera,
2816
- multipleCameras
3271
+ canFlip,
3272
+ flash,
3273
+ capturePhotoWith
2817
3274
  ]
2818
3275
  );
2819
3276
  }
3277
+
3278
+ // src/components/CloudLibrarySheet.tsx
3279
+ var import_react12 = require("react");
3280
+ var import_jsx_runtime18 = require("react/jsx-runtime");
3281
+ var PAGE_SIZE = 60;
3282
+ function CloudLibrarySheet({
3283
+ open,
3284
+ onClose,
3285
+ items,
3286
+ loading,
3287
+ busy = false,
3288
+ onOpenItem,
3289
+ onUpload,
3290
+ title = "Your media"
3291
+ }) {
3292
+ const [visibleCount, setVisibleCount] = (0, import_react12.useState)(PAGE_SIZE);
3293
+ (0, import_react12.useEffect)(() => {
3294
+ if (open) setVisibleCount(PAGE_SIZE);
3295
+ }, [open]);
3296
+ if (!open) return null;
3297
+ const uploadThenClose = onUpload ? () => {
3298
+ onClose();
3299
+ onUpload();
3300
+ } : null;
3301
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
3302
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3303
+ "div",
3304
+ {
3305
+ className: "flex shrink-0 items-center justify-between bg-black/80 px-4",
3306
+ style: safeTop,
3307
+ children: [
3308
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("h2", { className: "py-4 text-[17px] font-semibold text-white", children: title }),
3309
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex items-center gap-2", children: [
3310
+ uploadThenClose ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3311
+ "button",
3312
+ {
3313
+ type: "button",
3314
+ onClick: uploadThenClose,
3315
+ className: "flex h-10 touch-manipulation items-center gap-1.5 rounded-full bg-white/10 px-3.5 text-[13px] font-medium text-white transition-colors hover:bg-white/20",
3316
+ children: [
3317
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(UploadIcon, { className: "h-4 w-4" }),
3318
+ "Upload"
3319
+ ]
3320
+ }
3321
+ ) : null,
3322
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3323
+ "button",
3324
+ {
3325
+ type: "button",
3326
+ onClick: onClose,
3327
+ "aria-label": "Close library",
3328
+ className: "flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20",
3329
+ children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(XIcon, { className: "h-5 w-5" })
3330
+ }
3331
+ )
3332
+ ] })
3333
+ ]
3334
+ }
3335
+ ),
3336
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3337
+ "div",
3338
+ {
3339
+ className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-1",
3340
+ style: safeBottom,
3341
+ children: [
3342
+ loading ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Loader2Icon, { className: "h-6 w-6 animate-spin text-white/60" }) }) : items.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "flex h-full flex-col items-center justify-center gap-3 text-white/60", children: [
3343
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(ImageOffIcon, { className: "h-8 w-8" }),
3344
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "text-sm", children: "No photos or videos in your cloud yet." }),
3345
+ uploadThenClose ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3346
+ "button",
3347
+ {
3348
+ type: "button",
3349
+ onClick: uploadThenClose,
3350
+ className: "flex h-11 touch-manipulation items-center gap-2 rounded-full bg-white/10 px-5 text-sm font-medium text-white transition-colors hover:bg-white/20",
3351
+ children: [
3352
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(UploadIcon, { className: "h-4 w-4" }),
3353
+ "Upload from this device"
3354
+ ]
3355
+ }
3356
+ ) : null
3357
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "grid grid-cols-3 gap-0.5 sm:grid-cols-5 md:grid-cols-7", children: items.slice(0, visibleCount).map((item) => /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3358
+ "button",
3359
+ {
3360
+ type: "button",
3361
+ onClick: () => onOpenItem(item.id),
3362
+ "aria-label": `Open ${item.fileName}`,
3363
+ className: "relative aspect-square overflow-hidden bg-white/5 transition-opacity active:opacity-70",
3364
+ children: [
3365
+ item.thumbnail,
3366
+ item.kind === "video" && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3367
+ PlayIcon,
3368
+ {
3369
+ className: "absolute bottom-1.5 left-1.5 h-4 w-4 text-white drop-shadow",
3370
+ fill: "currentColor"
3371
+ }
3372
+ )
3373
+ ]
3374
+ },
3375
+ item.id
3376
+ )) }),
3377
+ !loading && items.length > visibleCount && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "flex justify-center py-4", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3378
+ "button",
3379
+ {
3380
+ type: "button",
3381
+ onClick: () => setVisibleCount((n) => n + PAGE_SIZE),
3382
+ className: "h-11 touch-manipulation rounded-full bg-white/10 px-6 text-sm font-medium text-white",
3383
+ children: [
3384
+ "Show more (",
3385
+ items.length - visibleCount,
3386
+ " left)"
3387
+ ]
3388
+ }
3389
+ ) }),
3390
+ busy && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center bg-black/40", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Loader2Icon, { className: "h-6 w-6 animate-spin text-white" }) })
3391
+ ]
3392
+ }
3393
+ )
3394
+ ] });
3395
+ }
2820
3396
  //# sourceMappingURL=react.cjs.map