react-native-image-stitcher 0.11.0 → 0.12.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/CHANGELOG.md +116 -0
- package/README.md +28 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +3 -2
- package/dist/camera/ARCameraView.d.ts +10 -0
- package/dist/camera/ARCameraView.js +1 -0
- package/dist/camera/Camera.d.ts +20 -0
- package/dist/camera/Camera.js +175 -6
- package/dist/camera/OrientationDriftModal.d.ts +83 -0
- package/dist/camera/OrientationDriftModal.js +159 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +13 -1
- package/dist/camera/PanoramaBandOverlay.js +106 -45
- package/dist/camera/PanoramaSettingsModal.js +15 -1
- package/dist/camera/ViewportCropOverlay.d.ts +35 -31
- package/dist/camera/ViewportCropOverlay.js +39 -30
- package/dist/camera/useDeviceOrientation.d.ts +18 -9
- package/dist/camera/useDeviceOrientation.js +18 -9
- package/dist/camera/useOrientationDrift.d.ts +104 -0
- package/dist/camera/useOrientationDrift.js +120 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -1
- package/dist/stitching/incremental.d.ts +5 -3
- package/dist/stitching/useStitcherWorklet.js +25 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +7 -1
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +4 -3
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +9 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -7
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +58 -1
- package/package.json +2 -1
- package/src/camera/ARCameraView.tsx +18 -1
- package/src/camera/Camera.tsx +280 -13
- package/src/camera/OrientationDriftModal.tsx +224 -0
- package/src/camera/PanoramaBandOverlay.tsx +135 -49
- package/src/camera/PanoramaSettingsModal.tsx +14 -0
- package/src/camera/ViewportCropOverlay.tsx +52 -30
- package/src/camera/__tests__/useOrientationDrift.test.ts +169 -0
- package/src/camera/useDeviceOrientation.ts +18 -9
- package/src/camera/useOrientationDrift.ts +172 -0
- package/src/index.ts +13 -0
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +202 -0
- package/src/stitching/incremental.ts +5 -3
- package/src/stitching/useStitcherWorklet.ts +25 -0
package/src/camera/Camera.tsx
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
StyleSheet,
|
|
53
53
|
Text,
|
|
54
54
|
View,
|
|
55
|
+
useWindowDimensions,
|
|
55
56
|
type StyleProp,
|
|
56
57
|
type ViewStyle,
|
|
57
58
|
} from 'react-native';
|
|
@@ -82,7 +83,9 @@ import {
|
|
|
82
83
|
} from './buildPanoramaInitialSettings';
|
|
83
84
|
import { isLowMemDevice } from './lowMemDevice';
|
|
84
85
|
import { useCapture } from './useCapture';
|
|
85
|
-
import { useDeviceOrientation } from './useDeviceOrientation';
|
|
86
|
+
import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrientation';
|
|
87
|
+
import { useOrientationDrift } from './useOrientationDrift';
|
|
88
|
+
import { OrientationDriftModal } from './OrientationDriftModal';
|
|
86
89
|
import {
|
|
87
90
|
getIncrementalNativeModule,
|
|
88
91
|
incrementalStitcherIsAvailable,
|
|
@@ -293,6 +296,27 @@ export interface CameraProps {
|
|
|
293
296
|
onFramesDropped?: (info: FramesDroppedInfo) => void;
|
|
294
297
|
onError?: (err: CameraError) => void;
|
|
295
298
|
|
|
299
|
+
/**
|
|
300
|
+
* v0.12.0 — fires when the SDK auto-abandons an in-progress
|
|
301
|
+
* capture without producing output. `reason` is a string union
|
|
302
|
+
* so future reasons (network loss, low memory, etc.) can be added
|
|
303
|
+
* without breaking the callback signature.
|
|
304
|
+
*
|
|
305
|
+
* Currently the only reason in v0.12 is `'orientation-drift'`:
|
|
306
|
+
* the user rotated the device between Mode A (landscape + vertical
|
|
307
|
+
* pan) and Mode B (portrait + horizontal pan) mid-capture. The
|
|
308
|
+
* engine docstring at `incremental.ts:373-403` is explicit that
|
|
309
|
+
* cross-mode capture is "best-effort, not supported," so the SDK
|
|
310
|
+
* decisively cancels the capture (`incremental.cancel()`) and
|
|
311
|
+
* surfaces `OrientationDriftModal` to explain what happened.
|
|
312
|
+
*
|
|
313
|
+
* Hosts use this callback to clean up their own state (e.g., reset
|
|
314
|
+
* a wizard step, log telemetry, surface their own retry UX in
|
|
315
|
+
* addition to the SDK's built-in modal). No `onCapture` will fire
|
|
316
|
+
* for an abandoned capture.
|
|
317
|
+
*/
|
|
318
|
+
onCaptureAbandoned?: (reason: 'orientation-drift') => void;
|
|
319
|
+
|
|
296
320
|
/**
|
|
297
321
|
* Optional host-supplied vision-camera frame processor.
|
|
298
322
|
*
|
|
@@ -656,11 +680,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
656
680
|
onLensChange,
|
|
657
681
|
onFramesDropped,
|
|
658
682
|
onError,
|
|
683
|
+
onCaptureAbandoned,
|
|
659
684
|
frameProcessor: hostFrameProcessor,
|
|
660
685
|
engine = 'batch-keyframe',
|
|
661
686
|
} = props;
|
|
662
687
|
|
|
663
688
|
const insets = useSafeAreaInsets();
|
|
689
|
+
// v0.12.0 — JS-layout orientation independent of device-physical.
|
|
690
|
+
// `useWindowDimensions().width > height` tells us if the OS
|
|
691
|
+
// rotated the framebuffer (only happens for non-locked hosts in
|
|
692
|
+
// device-landscape). Combined with `useDeviceOrientation()` to
|
|
693
|
+
// pick the JS edge corresponding to the home-indicator side of
|
|
694
|
+
// the device — see `homeIndicatorEdge` below.
|
|
695
|
+
const jsWindow = useWindowDimensions();
|
|
696
|
+
const jsLandscape = jsWindow.width > jsWindow.height;
|
|
664
697
|
|
|
665
698
|
// ── State ───────────────────────────────────────────────────────
|
|
666
699
|
const [arPreference, setArPreference] = useState(
|
|
@@ -857,6 +890,64 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
857
890
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
858
891
|
useEffect(() => () => { fpDriver.stop(); }, []);
|
|
859
892
|
|
|
893
|
+
// ── v0.12.0 — Orientation drift detection + auto-abandon ────────
|
|
894
|
+
//
|
|
895
|
+
// The incremental engine supports both portrait (Mode B, horizontal
|
|
896
|
+
// pan) and landscape (Mode A, vertical pan) capture as first-class,
|
|
897
|
+
// but the docstring at `incremental.ts:373-403` is explicit that
|
|
898
|
+
// mixing them mid-capture is "best-effort, not supported" — the
|
|
899
|
+
// output rotation becomes ambiguous and the stitched panorama is
|
|
900
|
+
// malformed. v0.12 protects against this by snapshotting the
|
|
901
|
+
// orientation at `start()` and auto-cancelling the capture the
|
|
902
|
+
// instant the user rotates to a different orientation mid-flight.
|
|
903
|
+
//
|
|
904
|
+
// The modal is informational only — by the time it renders, the
|
|
905
|
+
// capture is already stopped. No Continue/Resume affordance per
|
|
906
|
+
// the engine spec.
|
|
907
|
+
const drift = useOrientationDrift(statusPhase === 'recording');
|
|
908
|
+
const [driftModalDismissed, setDriftModalDismissed] = useState(false);
|
|
909
|
+
// Reset the dismissed flag when a new capture starts (or any non-
|
|
910
|
+
// recording state) so the next drift event surfaces a fresh modal.
|
|
911
|
+
useEffect(() => {
|
|
912
|
+
if (statusPhase !== 'recording') setDriftModalDismissed(false);
|
|
913
|
+
}, [statusPhase]);
|
|
914
|
+
|
|
915
|
+
useEffect(() => {
|
|
916
|
+
if (!drift.drifted || statusPhase !== 'recording') return;
|
|
917
|
+
// Auto-abandon the in-flight capture. Order matches handleHoldEnd's
|
|
918
|
+
// "stitch" path but skips finalize:
|
|
919
|
+
// 1. Stop pumping frames so no new keyframes arrive mid-cancel.
|
|
920
|
+
// 2. Tell the native engine to drop accumulated state
|
|
921
|
+
// (`incremental.cancel()`).
|
|
922
|
+
// 3. Reset statusPhase back to idle.
|
|
923
|
+
// 4. Notify the host via `onCaptureAbandoned`.
|
|
924
|
+
//
|
|
925
|
+
// Wrapped in an IIFE because useEffect callbacks can't be async
|
|
926
|
+
// directly. Errors from `incremental.cancel()` are caught + sent
|
|
927
|
+
// through `onError` — abandonment must succeed even if the engine
|
|
928
|
+
// is in a weird state.
|
|
929
|
+
void (async () => {
|
|
930
|
+
fpDriver.stop();
|
|
931
|
+
try {
|
|
932
|
+
await incremental.cancel();
|
|
933
|
+
} catch (err) {
|
|
934
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
935
|
+
onError?.(new CameraError(
|
|
936
|
+
'PANORAMA_FINALIZE_FAILED',
|
|
937
|
+
`cancel after orientation drift failed: ${message}`,
|
|
938
|
+
err,
|
|
939
|
+
));
|
|
940
|
+
} finally {
|
|
941
|
+
setStatusPhase('idle');
|
|
942
|
+
setRecordingStartedAt(null);
|
|
943
|
+
onCaptureAbandoned?.('orientation-drift');
|
|
944
|
+
}
|
|
945
|
+
})();
|
|
946
|
+
// Deps: re-run whenever drift latches OR recording state changes.
|
|
947
|
+
// Other deps are stable refs / setters.
|
|
948
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
949
|
+
}, [drift.drifted, statusPhase]);
|
|
950
|
+
|
|
860
951
|
// v0.8.0 Phase 5 / v0.11.0 — frameProcessor prop semantics:
|
|
861
952
|
//
|
|
862
953
|
// - Host supplied? → use host's processor. The host's worklet
|
|
@@ -991,7 +1082,14 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
991
1082
|
// ARCameraView writes to its own tmp location; relocate to
|
|
992
1083
|
// photoOutputPath via the native FileBridge so both branches
|
|
993
1084
|
// return paths under the same dir.
|
|
994
|
-
|
|
1085
|
+
// v0.12.0 — pass deviceOrientation so the AR takePhoto's
|
|
1086
|
+
// native CIImage rotation matches the user's view. Pre-
|
|
1087
|
+
// v0.12 the native side hardcoded portrait, so landscape
|
|
1088
|
+
// photos came out sideways.
|
|
1089
|
+
const photo = await arViewRef.current.takePhoto({
|
|
1090
|
+
quality: 90,
|
|
1091
|
+
orientation: deviceOrientation,
|
|
1092
|
+
});
|
|
995
1093
|
try {
|
|
996
1094
|
await moveFile(photo.path, photoOutputPath);
|
|
997
1095
|
} catch (moveErr) {
|
|
@@ -1370,29 +1468,43 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1370
1468
|
)}
|
|
1371
1469
|
|
|
1372
1470
|
{/*
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1471
|
+
v0.12.0 — Orientation-aware bottom controls anchored to the
|
|
1472
|
+
physical home-indicator edge. The shutter follows the home-
|
|
1473
|
+
indicator regardless of host portrait-lock state:
|
|
1474
|
+
- locked + any device → JS-bottom (locked
|
|
1475
|
+
framebuffer maps device-bottom to JS-bottom always)
|
|
1476
|
+
- non-locked + device-portrait → JS-bottom
|
|
1477
|
+
- non-locked + device-landscape-L → JS-right
|
|
1478
|
+
- non-locked + device-landscape-R → JS-left
|
|
1479
|
+
Computed in `homeIndicatorEdge` which combines `jsLandscape`
|
|
1480
|
+
(from window dims) with `deviceOrientation` (sensor).
|
|
1380
1481
|
*/}
|
|
1381
1482
|
<View
|
|
1382
1483
|
pointerEvents="box-none"
|
|
1383
|
-
style={
|
|
1484
|
+
style={bottomAreaStyleForEdge(
|
|
1485
|
+
homeIndicatorEdge(jsLandscape, deviceOrientation),
|
|
1486
|
+
insets.bottom + 12,
|
|
1487
|
+
insets.top + 12,
|
|
1488
|
+
)}
|
|
1384
1489
|
>
|
|
1385
|
-
{/* Live-frame band — only visible while recording.
|
|
1490
|
+
{/* Live-frame band — only visible while recording. `vertical`
|
|
1491
|
+
is true when the home-indicator anchor is on a side edge
|
|
1492
|
+
(left or right), in which case the band is a vertical
|
|
1493
|
+
column. Otherwise it's a horizontal strip. */}
|
|
1386
1494
|
{statusPhase === 'recording' && (
|
|
1387
1495
|
<PanoramaBandOverlay
|
|
1388
1496
|
state={incrementalState}
|
|
1389
1497
|
frameUris={batchKeyframeThumbnails}
|
|
1390
1498
|
captureOrientation={deviceOrientation}
|
|
1499
|
+
vertical={isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}
|
|
1391
1500
|
/>
|
|
1392
1501
|
)}
|
|
1393
1502
|
|
|
1394
|
-
{/* Shutter row
|
|
1395
|
-
|
|
1503
|
+
{/* Shutter row. Horizontal row when home-indicator is on
|
|
1504
|
+
top/bottom (lens left / shutter center / AR right);
|
|
1505
|
+
vertical column when on left/right (slots stack along
|
|
1506
|
+
the narrow strip). Touch targets stay axis-aligned. */}
|
|
1507
|
+
<View style={bottomBarStyleForEdge(homeIndicatorEdge(jsLandscape, deviceOrientation))}>
|
|
1396
1508
|
<View style={styles.bottomBarLeft} />
|
|
1397
1509
|
<View style={styles.bottomBarCenter}>
|
|
1398
1510
|
<LensChip
|
|
@@ -1425,6 +1537,19 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1425
1537
|
onChange={setSettings}
|
|
1426
1538
|
onClose={() => setSettingsModalVisible(false)}
|
|
1427
1539
|
/>
|
|
1540
|
+
|
|
1541
|
+
{/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
|
|
1542
|
+
auto-abandoned the capture (the useEffect above stops the
|
|
1543
|
+
engine + transitions to idle + fires onCaptureAbandoned).
|
|
1544
|
+
Modal exists purely to explain WHY the capture was
|
|
1545
|
+
cancelled. Single OK button (no Continue) per the engine
|
|
1546
|
+
spec on cross-mode capture being best-effort, not supported. */}
|
|
1547
|
+
<OrientationDriftModal
|
|
1548
|
+
visible={drift.drifted && !driftModalDismissed}
|
|
1549
|
+
captureOrientation={drift.captureOrientation}
|
|
1550
|
+
currentOrientation={drift.currentOrientation}
|
|
1551
|
+
onAcknowledge={() => setDriftModalDismissed(true)}
|
|
1552
|
+
/>
|
|
1428
1553
|
</View>
|
|
1429
1554
|
);
|
|
1430
1555
|
}
|
|
@@ -1435,6 +1560,148 @@ function noop(): void {
|
|
|
1435
1560
|
}
|
|
1436
1561
|
|
|
1437
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* v0.12.0 — JS edge corresponding to the physical home-indicator
|
|
1565
|
+
* side of the device. This is where the shutter + controls anchor
|
|
1566
|
+
* to so they're always within thumb reach of the user's grip
|
|
1567
|
+
* (matching iOS Camera's behaviour).
|
|
1568
|
+
*
|
|
1569
|
+
* Combines two signals:
|
|
1570
|
+
* - `jsLandscape`: whether the OS rotated the framebuffer. True
|
|
1571
|
+
* only for non-locked hosts in device-landscape.
|
|
1572
|
+
* - `deviceOrient`: physical device orientation from the sensor.
|
|
1573
|
+
*
|
|
1574
|
+
* Truth table:
|
|
1575
|
+
* | jsLandscape | deviceOrient | edge |
|
|
1576
|
+
* |--- |--- |--- |
|
|
1577
|
+
* | false | any | bottom | (portrait JS coords —
|
|
1578
|
+
* | | | | device-bottom = JS-bottom
|
|
1579
|
+
* | | | | in both locked and
|
|
1580
|
+
* | | | | non-locked-portrait)
|
|
1581
|
+
* | true | landscape-left | right | (screen rotated, home
|
|
1582
|
+
* | | | | indicator on user-right)
|
|
1583
|
+
* | true | landscape-right | left | (mirror)
|
|
1584
|
+
*
|
|
1585
|
+
* Caveats:
|
|
1586
|
+
* - Non-locked + upside-down doesn't surface JS-top here because
|
|
1587
|
+
* upside-down doesn't change window dimensions; we can't
|
|
1588
|
+
* distinguish locked-portrait-with-device-flipped from
|
|
1589
|
+
* non-locked-portrait-with-screen-flipped-180°. Defaults to
|
|
1590
|
+
* JS-bottom which matches the more common locked case. Add
|
|
1591
|
+
* handling here when a host needs upside-down support.
|
|
1592
|
+
* - jsLandscape=true with non-landscape device shouldn't happen
|
|
1593
|
+
* in steady state — only during a transition mid-rotation.
|
|
1594
|
+
* Falls through to 'right' as a defensive default.
|
|
1595
|
+
*/
|
|
1596
|
+
type HomeIndicatorEdge = 'bottom' | 'top' | 'left' | 'right';
|
|
1597
|
+
|
|
1598
|
+
function homeIndicatorEdge(
|
|
1599
|
+
jsLandscape: boolean,
|
|
1600
|
+
deviceOrient: DeviceOrientation,
|
|
1601
|
+
): HomeIndicatorEdge {
|
|
1602
|
+
if (!jsLandscape) return 'bottom';
|
|
1603
|
+
if (deviceOrient === 'landscape-left') return 'right';
|
|
1604
|
+
if (deviceOrient === 'landscape-right') return 'left';
|
|
1605
|
+
return 'right';
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* v0.12.0 — true when the anchor edge is on a side (left/right), so
|
|
1611
|
+
* the band + shutter row need to be vertical strips. Top/bottom
|
|
1612
|
+
* anchors yield horizontal strips.
|
|
1613
|
+
*/
|
|
1614
|
+
function isSideEdge(edge: HomeIndicatorEdge): boolean {
|
|
1615
|
+
return edge === 'left' || edge === 'right';
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* v0.12.0 — bottom-controls outer container positioning. Anchors
|
|
1621
|
+
* to the home-indicator JS edge with the appropriate flex direction
|
|
1622
|
+
* so the band sits on the viewport side of the shutter (toward the
|
|
1623
|
+
* camera preview centre).
|
|
1624
|
+
*/
|
|
1625
|
+
function bottomAreaStyleForEdge(
|
|
1626
|
+
edge: HomeIndicatorEdge,
|
|
1627
|
+
bottomInsetPx: number,
|
|
1628
|
+
topInsetPx: number,
|
|
1629
|
+
): ViewStyle {
|
|
1630
|
+
switch (edge) {
|
|
1631
|
+
case 'bottom':
|
|
1632
|
+
// Band above shutter row, both at JS-bottom. JSX order
|
|
1633
|
+
// [band, shutter] + flexDirection 'column' = band at top of
|
|
1634
|
+
// stack (closer to screen centre), shutter at JS-bottom.
|
|
1635
|
+
return {
|
|
1636
|
+
position: 'absolute',
|
|
1637
|
+
left: 0,
|
|
1638
|
+
right: 0,
|
|
1639
|
+
bottom: 0,
|
|
1640
|
+
flexDirection: 'column',
|
|
1641
|
+
alignItems: 'stretch',
|
|
1642
|
+
paddingBottom: bottomInsetPx,
|
|
1643
|
+
};
|
|
1644
|
+
case 'top':
|
|
1645
|
+
// Mirror of bottom. column-reverse so JSX [band, shutter]
|
|
1646
|
+
// renders [shutter, band] in JS, shutter at JS-top, band
|
|
1647
|
+
// below it (toward screen centre).
|
|
1648
|
+
return {
|
|
1649
|
+
position: 'absolute',
|
|
1650
|
+
left: 0,
|
|
1651
|
+
right: 0,
|
|
1652
|
+
top: 0,
|
|
1653
|
+
flexDirection: 'column-reverse',
|
|
1654
|
+
alignItems: 'stretch',
|
|
1655
|
+
paddingTop: topInsetPx,
|
|
1656
|
+
};
|
|
1657
|
+
case 'right':
|
|
1658
|
+
// Band to the left of shutter column, both at JS-right.
|
|
1659
|
+
// flexDirection 'row' + JSX [band, shutter] = band at JS-left
|
|
1660
|
+
// of container (screen centre side), shutter at JS-right.
|
|
1661
|
+
return {
|
|
1662
|
+
position: 'absolute',
|
|
1663
|
+
top: 0,
|
|
1664
|
+
bottom: 0,
|
|
1665
|
+
right: 0,
|
|
1666
|
+
flexDirection: 'row',
|
|
1667
|
+
alignItems: 'stretch',
|
|
1668
|
+
paddingRight: 12,
|
|
1669
|
+
};
|
|
1670
|
+
case 'left':
|
|
1671
|
+
// Mirror of right. row-reverse so JSX [band, shutter] gives
|
|
1672
|
+
// band at JS-right (screen centre side), shutter at JS-left.
|
|
1673
|
+
return {
|
|
1674
|
+
position: 'absolute',
|
|
1675
|
+
top: 0,
|
|
1676
|
+
bottom: 0,
|
|
1677
|
+
left: 0,
|
|
1678
|
+
flexDirection: 'row-reverse',
|
|
1679
|
+
alignItems: 'stretch',
|
|
1680
|
+
paddingLeft: 12,
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* v0.12.0 — inner shutter-row flex direction. Horizontal row for
|
|
1688
|
+
* top/bottom anchors; vertical column for left/right anchors so
|
|
1689
|
+
* the three slots (lens / shutter / AR) stack along the narrow
|
|
1690
|
+
* side strip. Buttons don't rotate — touch targets and text
|
|
1691
|
+
* orient correctly via either (a) un-rotated framebuffer under
|
|
1692
|
+
* portrait-lock or (b) OS-rotated framebuffer under non-locked.
|
|
1693
|
+
*/
|
|
1694
|
+
function bottomBarStyleForEdge(edge: HomeIndicatorEdge): ViewStyle {
|
|
1695
|
+
const vertical = isSideEdge(edge);
|
|
1696
|
+
return {
|
|
1697
|
+
flexDirection: vertical ? 'column' : 'row',
|
|
1698
|
+
paddingHorizontal: vertical ? 0 : 18,
|
|
1699
|
+
paddingVertical: vertical ? 18 : 0,
|
|
1700
|
+
alignItems: 'center',
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
|
|
1438
1705
|
const styles = StyleSheet.create({
|
|
1439
1706
|
container: {
|
|
1440
1707
|
flex: 1,
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* OrientationDriftModal — informational popup shown when the SDK
|
|
4
|
+
* auto-abandons an in-progress capture because the device rotated
|
|
5
|
+
* between Mode A (landscape + vertical pan) and Mode B (portrait
|
|
6
|
+
* + horizontal pan) mid-flight.
|
|
7
|
+
*
|
|
8
|
+
* ## When this modal appears
|
|
9
|
+
*
|
|
10
|
+
* In the v0.12 `<Camera>` integration, the modal is rendered while
|
|
11
|
+
* `useOrientationDrift(active).drifted === true`. By the time the
|
|
12
|
+
* modal renders, the capture has ALREADY been stopped (the
|
|
13
|
+
* `<Camera>` component's drift effect calls the engine's `stop()`
|
|
14
|
+
* the same render). The modal exists solely to explain to the
|
|
15
|
+
* user what happened — no "Continue" / "Resume" affordance because
|
|
16
|
+
* the engine docstring at `incremental.ts:373-403` is explicit
|
|
17
|
+
* that cross-mode capture is "best-effort, not supported" and
|
|
18
|
+
* continuing past drift produces malformed output.
|
|
19
|
+
*
|
|
20
|
+
* ## Layer-2 host usage
|
|
21
|
+
*
|
|
22
|
+
* Hosts using `CameraView` directly (rather than the flagship
|
|
23
|
+
* `<Camera>`) can compose this modal with `useOrientationDrift`
|
|
24
|
+
* for the same auto-abandon UX:
|
|
25
|
+
*
|
|
26
|
+
* const drift = useOrientationDrift(captureActive);
|
|
27
|
+
* useEffect(() => {
|
|
28
|
+
* if (drift.drifted) {
|
|
29
|
+
* // host abandons capture (engine stop + state cleanup)
|
|
30
|
+
* stopCapture();
|
|
31
|
+
* }
|
|
32
|
+
* }, [drift.drifted]);
|
|
33
|
+
*
|
|
34
|
+
* return <>
|
|
35
|
+
* <CameraView ... />
|
|
36
|
+
* <OrientationDriftModal
|
|
37
|
+
* visible={drift.drifted}
|
|
38
|
+
* captureOrientation={drift.captureOrientation}
|
|
39
|
+
* currentOrientation={drift.currentOrientation}
|
|
40
|
+
* onAcknowledge={dismissDriftModal}
|
|
41
|
+
* />
|
|
42
|
+
* </>;
|
|
43
|
+
*
|
|
44
|
+
* ## Accessibility
|
|
45
|
+
*
|
|
46
|
+
* Modal `role` defaults to RN's native dialog handling. The OK
|
|
47
|
+
* button carries an `accessibilityRole='button'` + label. Body
|
|
48
|
+
* text uses `accessibilityRole='text'` so the orientation summary
|
|
49
|
+
* is read by VoiceOver / TalkBack.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import React from 'react';
|
|
53
|
+
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
54
|
+
|
|
55
|
+
import { type DeviceOrientation } from './useDeviceOrientation';
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export interface OrientationDriftModalProps {
|
|
59
|
+
/**
|
|
60
|
+
* Show / hide. In the `<Camera>` integration this is driven by
|
|
61
|
+
* the latched `drifted` flag from `useOrientationDrift`.
|
|
62
|
+
*/
|
|
63
|
+
visible: boolean;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Orientation the capture started in. Shown in the body copy
|
|
67
|
+
* ("Capture started in PORTRAIT") so the user understands the
|
|
68
|
+
* baseline. `undefined` is tolerated (the modal hides the line);
|
|
69
|
+
* the prop is optional only to mirror `useOrientationDrift`'s
|
|
70
|
+
* return shape (which has `undefined` when inactive). When the
|
|
71
|
+
* modal is `visible`, drift detection means this was non-
|
|
72
|
+
* undefined at the moment the flag latched — so undefined here
|
|
73
|
+
* is unlikely in practice.
|
|
74
|
+
*/
|
|
75
|
+
captureOrientation: DeviceOrientation | undefined;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Current device orientation. Shown in the body copy ("now
|
|
79
|
+
* LANDSCAPE-LEFT") so the user understands what changed.
|
|
80
|
+
*/
|
|
81
|
+
currentOrientation: DeviceOrientation;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Tapped when the user dismisses with OK. By the time the
|
|
85
|
+
* modal renders the capture is already stopped; this callback
|
|
86
|
+
* exists only to clear the latched drift state so the next
|
|
87
|
+
* capture can start fresh.
|
|
88
|
+
*/
|
|
89
|
+
onAcknowledge: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pretty-print a `DeviceOrientation` for body copy. Returns the
|
|
95
|
+
* uppercase form because the modal copy reads as "Capture started
|
|
96
|
+
* in PORTRAIT, now LANDSCAPE-LEFT" — uppercase orientations stand
|
|
97
|
+
* out from the surrounding lowercase sentence.
|
|
98
|
+
*/
|
|
99
|
+
function formatOrientation(o: DeviceOrientation): string {
|
|
100
|
+
switch (o) {
|
|
101
|
+
case 'portrait':
|
|
102
|
+
return 'PORTRAIT';
|
|
103
|
+
case 'portrait-upside-down':
|
|
104
|
+
return 'PORTRAIT-UPSIDE-DOWN';
|
|
105
|
+
case 'landscape-left':
|
|
106
|
+
return 'LANDSCAPE-LEFT';
|
|
107
|
+
case 'landscape-right':
|
|
108
|
+
return 'LANDSCAPE-RIGHT';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
export function OrientationDriftModal(
|
|
114
|
+
props: OrientationDriftModalProps,
|
|
115
|
+
): React.JSX.Element {
|
|
116
|
+
const { visible, captureOrientation, currentOrientation, onAcknowledge } = props;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Modal
|
|
120
|
+
visible={visible}
|
|
121
|
+
transparent
|
|
122
|
+
animationType="fade"
|
|
123
|
+
onRequestClose={onAcknowledge}
|
|
124
|
+
accessibilityLabel="Capture cancelled — orientation drift"
|
|
125
|
+
// v0.12.0 — see PanoramaSettingsModal for the same prop's
|
|
126
|
+
// rationale. Declaring all orientations prevents iOS from
|
|
127
|
+
// force-rotating the window to portrait when this modal opens
|
|
128
|
+
// mid-rotation, which would otherwise leave the underlying
|
|
129
|
+
// <Camera>'s ARSession in a stale-orientation state on dismiss.
|
|
130
|
+
supportedOrientations={[
|
|
131
|
+
'portrait',
|
|
132
|
+
'portrait-upside-down',
|
|
133
|
+
'landscape-left',
|
|
134
|
+
'landscape-right',
|
|
135
|
+
]}
|
|
136
|
+
>
|
|
137
|
+
<View style={styles.backdrop}>
|
|
138
|
+
<View style={styles.card}>
|
|
139
|
+
<Text style={styles.title} accessibilityRole="header">
|
|
140
|
+
Capture cancelled
|
|
141
|
+
</Text>
|
|
142
|
+
|
|
143
|
+
<Text style={styles.body} accessibilityRole="text">
|
|
144
|
+
Rotation detected mid-capture. Please hold the device
|
|
145
|
+
steady and try again.
|
|
146
|
+
</Text>
|
|
147
|
+
|
|
148
|
+
{captureOrientation !== undefined && (
|
|
149
|
+
<Text style={styles.subBody} accessibilityRole="text">
|
|
150
|
+
Capture started in {formatOrientation(captureOrientation)},
|
|
151
|
+
now {formatOrientation(currentOrientation)}.
|
|
152
|
+
</Text>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<Pressable
|
|
156
|
+
style={({ pressed }) => [
|
|
157
|
+
styles.button,
|
|
158
|
+
pressed && styles.buttonPressed,
|
|
159
|
+
]}
|
|
160
|
+
onPress={onAcknowledge}
|
|
161
|
+
accessibilityRole="button"
|
|
162
|
+
accessibilityLabel="OK"
|
|
163
|
+
>
|
|
164
|
+
<Text style={styles.buttonLabel}>OK</Text>
|
|
165
|
+
</Pressable>
|
|
166
|
+
</View>
|
|
167
|
+
</View>
|
|
168
|
+
</Modal>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
const styles = StyleSheet.create({
|
|
174
|
+
backdrop: {
|
|
175
|
+
flex: 1,
|
|
176
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
177
|
+
alignItems: 'center',
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
paddingHorizontal: 32,
|
|
180
|
+
},
|
|
181
|
+
card: {
|
|
182
|
+
backgroundColor: '#1c1c1e',
|
|
183
|
+
borderRadius: 14,
|
|
184
|
+
paddingHorizontal: 20,
|
|
185
|
+
paddingVertical: 24,
|
|
186
|
+
width: '100%',
|
|
187
|
+
maxWidth: 340,
|
|
188
|
+
},
|
|
189
|
+
title: {
|
|
190
|
+
color: '#fff',
|
|
191
|
+
fontSize: 18,
|
|
192
|
+
fontWeight: '600',
|
|
193
|
+
marginBottom: 12,
|
|
194
|
+
textAlign: 'center',
|
|
195
|
+
},
|
|
196
|
+
body: {
|
|
197
|
+
color: '#e5e5ea',
|
|
198
|
+
fontSize: 15,
|
|
199
|
+
lineHeight: 21,
|
|
200
|
+
textAlign: 'center',
|
|
201
|
+
marginBottom: 12,
|
|
202
|
+
},
|
|
203
|
+
subBody: {
|
|
204
|
+
color: '#8e8e93',
|
|
205
|
+
fontSize: 13,
|
|
206
|
+
lineHeight: 18,
|
|
207
|
+
textAlign: 'center',
|
|
208
|
+
marginBottom: 20,
|
|
209
|
+
},
|
|
210
|
+
button: {
|
|
211
|
+
backgroundColor: '#0a84ff',
|
|
212
|
+
borderRadius: 10,
|
|
213
|
+
paddingVertical: 12,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
},
|
|
216
|
+
buttonPressed: {
|
|
217
|
+
backgroundColor: '#0860c0',
|
|
218
|
+
},
|
|
219
|
+
buttonLabel: {
|
|
220
|
+
color: '#fff',
|
|
221
|
+
fontSize: 17,
|
|
222
|
+
fontWeight: '600',
|
|
223
|
+
},
|
|
224
|
+
});
|