@ksp-gonogo/kerbcast-react 1.11.0-rc.4 → 1.11.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.
@@ -38,7 +38,7 @@ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
38
38
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
39
39
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
40
40
  import { useRecordings } from "./hooks/useRecordings";
41
- import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
41
+ import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
42
42
  import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
43
43
  import { StandbyIcon } from "./StandbyIcon";
44
44
  import { isCameraDestroyed } from "./lifecycle";
@@ -740,10 +740,36 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
740
740
  // -------------------------------------------------------------------------
741
741
  // Display-size reporting (auto-resolution). The feed self-measures its
742
742
  // rendered box and reports it per-consumer; the sidecar maxes across
743
- // consumers. Reports the real w x h (part cams are not square). Disabled by
744
- // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
743
+ // consumers. Auto picks the resolution, never the shape: the request and
744
+ // the feed's own box keep the camera's configured capture aspect (16:9
745
+ // when unknown). Disabled by `renderSize="none"`. The video and the
746
+ // sidecar's render size let it ignore a resize that only follows the
747
+ // stream. See useReportDisplaySize for debounce / bucketing / the guard.
745
748
  // -------------------------------------------------------------------------
746
- useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
749
+ const baseW = camera?.maxWidth ?? 0;
750
+ const baseH = camera?.maxHeight ?? 0;
751
+ const hasBase = baseW > 0 && baseH > 0;
752
+ const feedAspect = hasBase ? baseW / baseH : DEFAULT_FEED_ASPECT;
753
+ const feedShape = useMemo(
754
+ () =>
755
+ ({
756
+ "--kc-feed-w": hasBase ? baseW : 16,
757
+ "--kc-feed-h": hasBase ? baseH : 9,
758
+ }) as React.CSSProperties,
759
+ [hasBase, baseW, baseH],
760
+ );
761
+ const renderedW = camera?.renderWidth ?? 0;
762
+ const renderedH = camera?.renderHeight ?? 0;
763
+ const renderedSize = useMemo(
764
+ () => ({ width: renderedW, height: renderedH }),
765
+ [renderedW, renderedH],
766
+ );
767
+ useReportDisplaySize(flightId, wrapRef, {
768
+ enabled: renderSize === "auto",
769
+ aspect: feedAspect,
770
+ videoRef,
771
+ streamSize: renderedSize,
772
+ });
747
773
 
748
774
  // -------------------------------------------------------------------------
749
775
  // UI state
@@ -1232,307 +1258,309 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1232
1258
  ) : null;
1233
1259
 
1234
1260
  return (
1235
- <Stage ref={wrapRef} $pinned={chromePinned}>
1236
- {flightId === null ? (
1237
- <>
1238
- <Empty>{emptyMessage}</Empty>
1239
- {topOverlay}
1240
- {actionBar}
1241
- </>
1242
- ) : (
1243
- <>
1244
- <StyledVideo
1245
- ref={attachVideo}
1246
- autoPlay
1247
- playsInline
1248
- muted
1249
- controls={false}
1250
- onClick={() => setChromePinned((v) => !v)}
1251
- />
1252
- {topOverlay}
1253
- {actionBar}
1254
- {qualityAvailable &&
1255
- qualityMenu.open &&
1256
- qualityMenu.position &&
1257
- camera &&
1258
- createPortal(
1259
- <QualityMenu
1260
- ref={qualityMenu.menuRef}
1261
- id={qualityMenuId}
1262
- role="menu"
1263
- aria-label="Quality"
1264
- style={qualityMenu.position}
1265
- >
1266
- <CameraMenuItem
1267
- type="button"
1268
- role="menuitemradio"
1269
- aria-checked={camera.viewerQuality == null}
1270
- $selected={camera.viewerQuality == null}
1271
- onClick={() => selectQuality(null)}
1261
+ <FeedBox style={feedShape}>
1262
+ <Stage ref={wrapRef} $pinned={chromePinned}>
1263
+ {flightId === null ? (
1264
+ <>
1265
+ <Empty>{emptyMessage}</Empty>
1266
+ {topOverlay}
1267
+ {actionBar}
1268
+ </>
1269
+ ) : (
1270
+ <>
1271
+ <StyledVideo
1272
+ ref={attachVideo}
1273
+ autoPlay
1274
+ playsInline
1275
+ muted
1276
+ controls={false}
1277
+ onClick={() => setChromePinned((v) => !v)}
1278
+ />
1279
+ {topOverlay}
1280
+ {actionBar}
1281
+ {qualityAvailable &&
1282
+ qualityMenu.open &&
1283
+ qualityMenu.position &&
1284
+ camera &&
1285
+ createPortal(
1286
+ <QualityMenu
1287
+ ref={qualityMenu.menuRef}
1288
+ id={qualityMenuId}
1289
+ role="menu"
1290
+ aria-label="Quality"
1291
+ style={qualityMenu.position}
1272
1292
  >
1273
- Auto
1274
- </CameraMenuItem>
1275
- {QUALITY_PRESETS.map(({ preset, label, scale }) => (
1276
1293
  <CameraMenuItem
1277
- key={preset}
1278
1294
  type="button"
1279
1295
  role="menuitemradio"
1280
- aria-checked={camera.viewerQuality === preset}
1281
- $selected={camera.viewerQuality === preset}
1282
- onClick={() => selectQuality(preset)}
1296
+ aria-checked={camera.viewerQuality == null}
1297
+ $selected={camera.viewerQuality == null}
1298
+ onClick={() => selectQuality(null)}
1283
1299
  >
1284
- {label} ({presetDim(camera.operatorWidth, scale)}×
1285
- {presetDim(camera.operatorHeight, scale)})
1300
+ Auto
1286
1301
  </CameraMenuItem>
1287
- ))}
1288
- <QualityMeta role="status">
1289
- now {camera.renderWidth}×{camera.renderHeight}
1290
- {qualityThrottled ? " · throttled" : ""}
1291
- </QualityMeta>
1292
- </QualityMenu>,
1293
- document.body,
1294
- )}
1295
- {trackingAvailable &&
1296
- trackingMenu.open &&
1297
- trackingMenu.position &&
1298
- camera &&
1299
- createPortal(
1300
- <QualityMenu
1301
- ref={trackingMenu.menuRef}
1302
- id={trackingMenuId}
1303
- role="menu"
1304
- aria-label="Auto-track"
1305
- style={trackingMenu.position}
1306
- >
1307
- <CameraMenuItem
1308
- type="button"
1309
- role="menuitemradio"
1310
- aria-checked={trackMode === TrackMode.ActiveVessel}
1311
- $selected={trackMode === TrackMode.ActiveVessel}
1312
- onClick={() => selectTrack(TrackMode.ActiveVessel)}
1313
- >
1314
- Track active vessel
1315
- </CameraMenuItem>
1316
- <CameraMenuItem
1317
- type="button"
1318
- role="menuitemradio"
1319
- aria-checked={trackMode === TrackMode.Target}
1320
- $selected={trackMode === TrackMode.Target}
1321
- onClick={() => selectTrack(TrackMode.Target)}
1322
- >
1323
- Track target
1324
- </CameraMenuItem>
1325
- </QualityMenu>,
1326
- document.body,
1327
- )}
1328
- {commandTargetAvailable &&
1329
- commandTargetMenu.open &&
1330
- commandTargetMenu.position &&
1331
- createPortal(
1332
- <CameraMenu
1333
- ref={commandTargetMenu.menuRef}
1334
- id={commandTargetMenuId}
1335
- role="menu"
1336
- aria-label="Target"
1337
- style={commandTargetMenu.position}
1338
- >
1339
- {commandTargets.map((t) => {
1340
- const eligible = t.eligibility === CommandTargetEligibility.Ok;
1341
- const current = t.persistentId === commandTargetId;
1342
- return (
1302
+ {QUALITY_PRESETS.map(({ preset, label, scale }) => (
1343
1303
  <CameraMenuItem
1344
- key={t.persistentId}
1304
+ key={preset}
1345
1305
  type="button"
1346
1306
  role="menuitemradio"
1347
- aria-checked={current}
1348
- $selected={current}
1349
- disabled={!eligible}
1350
- title={eligible ? undefined : t.eligibilityText}
1351
- onClick={() => selectCommandTarget(t.persistentId)}
1307
+ aria-checked={camera.viewerQuality === preset}
1308
+ $selected={camera.viewerQuality === preset}
1309
+ onClick={() => selectQuality(preset)}
1352
1310
  >
1353
- {t.vesselName}
1354
- {t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : ""}
1355
- {eligible ? "" : ` · ${t.eligibilityText}`}
1311
+ {label} ({presetDim(camera.operatorWidth, scale)}×
1312
+ {presetDim(camera.operatorHeight, scale)})
1356
1313
  </CameraMenuItem>
1357
- );
1358
- })}
1359
- </CameraMenu>,
1360
- document.body,
1314
+ ))}
1315
+ <QualityMeta role="status">
1316
+ now {camera.renderWidth}×{camera.renderHeight}
1317
+ {qualityThrottled ? " · throttled" : ""}
1318
+ </QualityMeta>
1319
+ </QualityMenu>,
1320
+ document.body,
1321
+ )}
1322
+ {trackingAvailable &&
1323
+ trackingMenu.open &&
1324
+ trackingMenu.position &&
1325
+ camera &&
1326
+ createPortal(
1327
+ <QualityMenu
1328
+ ref={trackingMenu.menuRef}
1329
+ id={trackingMenuId}
1330
+ role="menu"
1331
+ aria-label="Auto-track"
1332
+ style={trackingMenu.position}
1333
+ >
1334
+ <CameraMenuItem
1335
+ type="button"
1336
+ role="menuitemradio"
1337
+ aria-checked={trackMode === TrackMode.ActiveVessel}
1338
+ $selected={trackMode === TrackMode.ActiveVessel}
1339
+ onClick={() => selectTrack(TrackMode.ActiveVessel)}
1340
+ >
1341
+ Track active vessel
1342
+ </CameraMenuItem>
1343
+ <CameraMenuItem
1344
+ type="button"
1345
+ role="menuitemradio"
1346
+ aria-checked={trackMode === TrackMode.Target}
1347
+ $selected={trackMode === TrackMode.Target}
1348
+ onClick={() => selectTrack(TrackMode.Target)}
1349
+ >
1350
+ Track target
1351
+ </CameraMenuItem>
1352
+ </QualityMenu>,
1353
+ document.body,
1354
+ )}
1355
+ {commandTargetAvailable &&
1356
+ commandTargetMenu.open &&
1357
+ commandTargetMenu.position &&
1358
+ createPortal(
1359
+ <CameraMenu
1360
+ ref={commandTargetMenu.menuRef}
1361
+ id={commandTargetMenuId}
1362
+ role="menu"
1363
+ aria-label="Target"
1364
+ style={commandTargetMenu.position}
1365
+ >
1366
+ {commandTargets.map((t) => {
1367
+ const eligible = t.eligibility === CommandTargetEligibility.Ok;
1368
+ const current = t.persistentId === commandTargetId;
1369
+ return (
1370
+ <CameraMenuItem
1371
+ key={t.persistentId}
1372
+ type="button"
1373
+ role="menuitemradio"
1374
+ aria-checked={current}
1375
+ $selected={current}
1376
+ disabled={!eligible}
1377
+ title={eligible ? undefined : t.eligibilityText}
1378
+ onClick={() => selectCommandTarget(t.persistentId)}
1379
+ >
1380
+ {t.vesselName}
1381
+ {t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : ""}
1382
+ {eligible ? "" : ` · ${t.eligibilityText}`}
1383
+ </CameraMenuItem>
1384
+ );
1385
+ })}
1386
+ </CameraMenu>,
1387
+ document.body,
1388
+ )}
1389
+ {commandState &&
1390
+ !outOfFlight &&
1391
+ (
1392
+ <>
1393
+ {commandState.blank && <CommandBlank reason={commandState.blank} />}
1394
+ {targetBracketsAvailable && bracketsOn && (
1395
+ <CommandBrackets
1396
+ state={commandState}
1397
+ displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
1398
+ utSource={commandDisplayUt?.source ?? null}
1399
+ video={videoEl}
1400
+ fallbackSize={
1401
+ camera ? { width: camera.renderWidth, height: camera.renderHeight } : null
1402
+ }
1403
+ overlay={targetBracketOverlay}
1404
+ />
1405
+ )}
1406
+ </>
1407
+ )}
1408
+ {outOfFlight ? (
1409
+ showStandbyIcon && (
1410
+ <StandbyOverlay role="status" aria-label="Standby, no active flight">
1411
+ <StandbyIconWrap>
1412
+ <StandbyIcon size={40} />
1413
+ </StandbyIconWrap>
1414
+ </StandbyOverlay>
1415
+ )
1416
+ ) : (
1417
+ isDestroyed && (
1418
+ <SignalLostOverlay role="status" aria-label="Signal lost">
1419
+ <SignalLostText $animated={effectiveShowStatic}>
1420
+ SIGNAL LOST
1421
+ </SignalLostText>
1422
+ </SignalLostOverlay>
1423
+ )
1361
1424
  )}
1362
- {commandState &&
1363
- !outOfFlight &&
1364
- (
1425
+ {!outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (
1365
1426
  <>
1366
- {commandState.blank && <CommandBlank reason={commandState.blank} />}
1367
- {targetBracketsAvailable && bracketsOn && (
1368
- <CommandBrackets
1369
- state={commandState}
1370
- displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
1371
- utSource={commandDisplayUt?.source ?? null}
1372
- video={videoEl}
1373
- fallbackSize={
1374
- camera ? { width: camera.renderWidth, height: camera.renderHeight } : null
1375
- }
1376
- overlay={targetBracketOverlay}
1377
- />
1378
- )}
1427
+ <StaleScrim aria-hidden="true" />
1428
+ <StaleBadge role="status" aria-label="Feed stale">
1429
+ <StaleIcon />
1430
+ Stale
1431
+ </StaleBadge>
1379
1432
  </>
1380
1433
  )}
1381
- {outOfFlight ? (
1382
- showStandbyIcon && (
1383
- <StandbyOverlay role="status" aria-label="Standby, no active flight">
1384
- <StandbyIconWrap>
1385
- <StandbyIcon size={40} />
1386
- </StandbyIconWrap>
1387
- </StandbyOverlay>
1388
- )
1389
- ) : (
1390
- isDestroyed && (
1391
- <SignalLostOverlay role="status" aria-label="Signal lost">
1392
- <SignalLostText $animated={effectiveShowStatic}>
1393
- SIGNAL LOST
1394
- </SignalLostText>
1395
- </SignalLostOverlay>
1396
- )
1397
- )}
1398
- {!outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (
1399
- <>
1400
- <StaleScrim aria-hidden="true" />
1401
- <StaleBadge role="status" aria-label="Feed stale">
1402
- <StaleIcon />
1403
- Stale
1404
- </StaleBadge>
1405
- </>
1406
- )}
1407
- {recordingActive && armingActive && (
1408
- <ArmingBadge role="status" aria-label="Arming">
1409
- <ArmingDot aria-hidden="true" />
1410
- ARMING
1411
- </ArmingBadge>
1412
- )}
1413
- {recordingActive && !armingActive && (
1414
- <RecBadge role="status" aria-label="Recording">
1415
- <RecDot aria-hidden="true" />
1416
- REC {formatElapsed(recordingElapsedMs)}
1417
- </RecBadge>
1418
- )}
1419
- {showZoom && (
1420
- <ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
1421
- <ZoomButton
1422
- type="button"
1423
- aria-label="Zoom in"
1424
- $pos="top"
1425
- disabled={manualDisabled}
1426
- onPointerDown={() =>
1427
- controllerRef.current?.setZoomRate(1)
1428
- }
1429
- onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1430
- onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
1431
- onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
1432
- onClick={(e) => {
1433
- if (e.detail === 0) controllerRef.current?.nudgeZoom(-1);
1434
- }}
1435
- >
1436
- +
1437
- </ZoomButton>
1438
- <FovSlider
1439
- type="range"
1440
- aria-label="Zoom"
1441
- min={camera.fovMin}
1442
- max={camera.fovMax}
1443
- step={0.5}
1444
- value={sliderFov}
1445
- disabled={manualDisabled}
1446
- onChange={(e) => {
1447
- const v = Number(e.target.value);
1448
- controllerRef.current?.fovSliderInput(v);
1449
- }}
1450
- onPointerDown={() => {
1451
- controllerRef.current?.setFovSliderDragging(true);
1452
- }}
1453
- onPointerUp={() => {
1454
- controllerRef.current?.setFovSliderDragging(false);
1455
- }}
1456
- onPointerCancel={() => {
1457
- controllerRef.current?.setFovSliderDragging(false);
1458
- }}
1459
- />
1460
- <ZoomButton
1461
- type="button"
1462
- aria-label="Zoom out"
1463
- $pos="bottom"
1464
- disabled={manualDisabled}
1465
- onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
1466
- onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1467
- onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
1468
- onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
1469
- onClick={(e) => {
1470
- if (e.detail === 0) controllerRef.current?.nudgeZoom(1);
1471
- }}
1472
- >
1473
- &#8722;
1474
- </ZoomButton>
1475
- </ZoomControlsWrap>
1476
- )}
1477
- {showPan && (
1478
- <PanControl
1479
- role="group"
1480
- aria-label="Pan camera"
1481
- $disabled={manualDisabled}
1482
- aria-hidden={manualDisabled}
1483
- >
1484
- <PanArrow
1485
- type="button"
1486
- $dir="up"
1487
- aria-label="Pan up"
1488
- disabled={manualDisabled || !supportsPitch}
1489
- onClick={() => controllerRef.current?.nudgePan(0, 1)}
1490
- >
1491
- &#9650;
1492
- </PanArrow>
1493
- <PanArrow
1494
- type="button"
1495
- $dir="down"
1496
- aria-label="Pan down"
1497
- disabled={manualDisabled || !supportsPitch}
1498
- onClick={() => controllerRef.current?.nudgePan(0, -1)}
1499
- >
1500
- &#9660;
1501
- </PanArrow>
1502
- <PanArrow
1503
- type="button"
1504
- $dir="left"
1505
- aria-label="Pan left"
1506
- disabled={manualDisabled}
1507
- onClick={() => controllerRef.current?.nudgePan(-1, 0)}
1508
- >
1509
- &#9664;
1510
- </PanArrow>
1511
- <PanArrow
1512
- type="button"
1513
- $dir="right"
1514
- aria-label="Pan right"
1515
- disabled={manualDisabled}
1516
- onClick={() => controllerRef.current?.nudgePan(1, 0)}
1434
+ {recordingActive && armingActive && (
1435
+ <ArmingBadge role="status" aria-label="Arming">
1436
+ <ArmingDot aria-hidden="true" />
1437
+ ARMING
1438
+ </ArmingBadge>
1439
+ )}
1440
+ {recordingActive && !armingActive && (
1441
+ <RecBadge role="status" aria-label="Recording">
1442
+ <RecDot aria-hidden="true" />
1443
+ REC {formatElapsed(recordingElapsedMs)}
1444
+ </RecBadge>
1445
+ )}
1446
+ {showZoom && (
1447
+ <ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
1448
+ <ZoomButton
1449
+ type="button"
1450
+ aria-label="Zoom in"
1451
+ $pos="top"
1452
+ disabled={manualDisabled}
1453
+ onPointerDown={() =>
1454
+ controllerRef.current?.setZoomRate(1)
1455
+ }
1456
+ onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1457
+ onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
1458
+ onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
1459
+ onClick={(e) => {
1460
+ if (e.detail === 0) controllerRef.current?.nudgeZoom(-1);
1461
+ }}
1462
+ >
1463
+ +
1464
+ </ZoomButton>
1465
+ <FovSlider
1466
+ type="range"
1467
+ aria-label="Zoom"
1468
+ min={camera.fovMin}
1469
+ max={camera.fovMax}
1470
+ step={0.5}
1471
+ value={sliderFov}
1472
+ disabled={manualDisabled}
1473
+ onChange={(e) => {
1474
+ const v = Number(e.target.value);
1475
+ controllerRef.current?.fovSliderInput(v);
1476
+ }}
1477
+ onPointerDown={() => {
1478
+ controllerRef.current?.setFovSliderDragging(true);
1479
+ }}
1480
+ onPointerUp={() => {
1481
+ controllerRef.current?.setFovSliderDragging(false);
1482
+ }}
1483
+ onPointerCancel={() => {
1484
+ controllerRef.current?.setFovSliderDragging(false);
1485
+ }}
1486
+ />
1487
+ <ZoomButton
1488
+ type="button"
1489
+ aria-label="Zoom out"
1490
+ $pos="bottom"
1491
+ disabled={manualDisabled}
1492
+ onPointerDown={() => controllerRef.current?.setZoomRate(-1)}
1493
+ onPointerUp={() => controllerRef.current?.setZoomRate(0)}
1494
+ onPointerLeave={() => controllerRef.current?.setZoomRate(0)}
1495
+ onPointerCancel={() => controllerRef.current?.setZoomRate(0)}
1496
+ onClick={(e) => {
1497
+ if (e.detail === 0) controllerRef.current?.nudgeZoom(1);
1498
+ }}
1499
+ >
1500
+ &#8722;
1501
+ </ZoomButton>
1502
+ </ZoomControlsWrap>
1503
+ )}
1504
+ {showPan && (
1505
+ <PanControl
1506
+ role="group"
1507
+ aria-label="Pan camera"
1508
+ $disabled={manualDisabled}
1509
+ aria-hidden={manualDisabled}
1517
1510
  >
1518
- &#9654;
1519
- </PanArrow>
1520
- <PanBall
1521
- aria-hidden="true"
1522
- title="Drag to pan"
1523
- onPointerDown={manualDisabled ? undefined : handleBallDown}
1524
- onPointerMove={manualDisabled ? undefined : handleBallMove}
1525
- onPointerUp={manualDisabled ? undefined : handleBallUp}
1526
- onPointerCancel={manualDisabled ? undefined : handleBallUp}
1527
- style={{
1528
- transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
1529
- }}
1530
- />
1531
- </PanControl>
1532
- )}
1533
- </>
1534
- )}
1535
- </Stage>
1511
+ <PanArrow
1512
+ type="button"
1513
+ $dir="up"
1514
+ aria-label="Pan up"
1515
+ disabled={manualDisabled || !supportsPitch}
1516
+ onClick={() => controllerRef.current?.nudgePan(0, 1)}
1517
+ >
1518
+ &#9650;
1519
+ </PanArrow>
1520
+ <PanArrow
1521
+ type="button"
1522
+ $dir="down"
1523
+ aria-label="Pan down"
1524
+ disabled={manualDisabled || !supportsPitch}
1525
+ onClick={() => controllerRef.current?.nudgePan(0, -1)}
1526
+ >
1527
+ &#9660;
1528
+ </PanArrow>
1529
+ <PanArrow
1530
+ type="button"
1531
+ $dir="left"
1532
+ aria-label="Pan left"
1533
+ disabled={manualDisabled}
1534
+ onClick={() => controllerRef.current?.nudgePan(-1, 0)}
1535
+ >
1536
+ &#9664;
1537
+ </PanArrow>
1538
+ <PanArrow
1539
+ type="button"
1540
+ $dir="right"
1541
+ aria-label="Pan right"
1542
+ disabled={manualDisabled}
1543
+ onClick={() => controllerRef.current?.nudgePan(1, 0)}
1544
+ >
1545
+ &#9654;
1546
+ </PanArrow>
1547
+ <PanBall
1548
+ aria-hidden="true"
1549
+ title="Drag to pan"
1550
+ onPointerDown={manualDisabled ? undefined : handleBallDown}
1551
+ onPointerMove={manualDisabled ? undefined : handleBallMove}
1552
+ onPointerUp={manualDisabled ? undefined : handleBallUp}
1553
+ onPointerCancel={manualDisabled ? undefined : handleBallUp}
1554
+ style={{
1555
+ transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
1556
+ }}
1557
+ />
1558
+ </PanControl>
1559
+ )}
1560
+ </>
1561
+ )}
1562
+ </Stage>
1563
+ </FeedBox>
1536
1564
  );
1537
1565
  },
1538
1566
  );
@@ -2147,7 +2175,27 @@ const Panel = styled.div`
2147
2175
  overflow: hidden;
2148
2176
  `;
2149
2177
 
2178
+ /*
2179
+ * Fills the host and centres the Stage at the camera's configured aspect
2180
+ * (--kc-feed-w / --kc-feed-h), so the picture fills the Stage and the host's
2181
+ * spare room sits outside it.
2182
+ */
2183
+ const FeedBox = styled.div`
2184
+ width: 100%;
2185
+ height: 100%;
2186
+ min-width: 0;
2187
+ min-height: 0;
2188
+ container-type: size;
2189
+ display: flex;
2190
+ align-items: center;
2191
+ justify-content: center;
2192
+ `;
2193
+
2150
2194
  const Stage = styled(Panel)<{ $pinned: boolean }>`
2195
+ width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
2196
+ height: auto;
2197
+ aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
2198
+ flex: none;
2151
2199
  padding: 0;
2152
2200
  gap: 0;
2153
2201
  position: relative;