@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2

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.
Files changed (49) hide show
  1. package/README.md +71 -0
  2. package/dist/AnchorMarkers.d.ts +128 -0
  3. package/dist/AnchorMarkers.js +162 -0
  4. package/dist/CameraFeed.d.ts +45 -4
  5. package/dist/CameraFeed.js +163 -23
  6. package/dist/CommandBrackets.d.ts +61 -12
  7. package/dist/CommandBrackets.js +106 -53
  8. package/dist/FeedActionBar.d.ts +7 -0
  9. package/dist/FeedActionBar.js +4 -4
  10. package/dist/KerbalFaceFeed.d.ts +1 -1
  11. package/dist/KerbalFaceFeed.js +1 -1
  12. package/dist/RecordingsTray.d.ts +6 -0
  13. package/dist/RecordingsTray.js +17 -1
  14. package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
  15. package/dist/hooks/useCommandDisplayUt.js +57 -16
  16. package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
  17. package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
  18. package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
  19. package/dist/hooks/useTargetBracketsPreference.js +18 -5
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.js +5 -1
  22. package/dist/menuPositioning.d.ts +8 -2
  23. package/dist/menuPositioning.js +30 -5
  24. package/dist/sharedAnchors.d.ts +3 -0
  25. package/dist/sharedAnchors.js +61 -0
  26. package/dist/videoBox.d.ts +10 -0
  27. package/dist/videoBox.js +40 -0
  28. package/package.json +2 -2
  29. package/src/AnchorMarkers.test.tsx +173 -0
  30. package/src/AnchorMarkers.tsx +341 -0
  31. package/src/CameraFeed.test.tsx +687 -6
  32. package/src/CameraFeed.tsx +240 -27
  33. package/src/CommandBrackets.test.tsx +204 -4
  34. package/src/CommandBrackets.tsx +170 -53
  35. package/src/FeedActionBar.test.ts +22 -0
  36. package/src/FeedActionBar.tsx +11 -4
  37. package/src/KerbalFaceFeed.tsx +1 -1
  38. package/src/RecordingsTray.test.tsx +15 -0
  39. package/src/RecordingsTray.tsx +25 -1
  40. package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
  41. package/src/hooks/useCommandDisplayUt.ts +61 -15
  42. package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
  43. package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
  44. package/src/hooks/useRecordings.test.tsx +6 -3
  45. package/src/hooks/useTargetBracketsPreference.ts +15 -5
  46. package/src/index.ts +23 -1
  47. package/src/menuPositioning.ts +31 -5
  48. package/src/sharedAnchors.ts +67 -0
  49. package/src/videoBox.ts +49 -0
@@ -1,4 +1,4 @@
1
- import type { CameraState, KerbcastClient } from "@ksp-gonogo/kerbcast";
1
+ import type { Anchor, CameraState, DisplayedFrameUt, KerbcastClient } from "@ksp-gonogo/kerbcast";
2
2
  import {
3
3
  CameraKind,
4
4
  CommandAimKind,
@@ -32,17 +32,20 @@ import {
32
32
  type FeedAction,
33
33
  type FeedActionBarEntry,
34
34
  } from "./FeedActionBar";
35
- import { CommandBlank, CommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
35
+ import { CommandBlank, LiveCommandBrackets, type CommandBracketOverlay } from "./CommandBrackets";
36
+ import { LiveAnchorMarkers, type AnchorOverlay } from "./AnchorMarkers";
36
37
  import { useCommandDisplayUt } from "./hooks/useCommandDisplayUt";
37
38
  import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
38
39
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
39
40
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
41
+ import { useKerbcastWaitingForSlot } from "./hooks/useKerbcastWaitingForSlot";
40
42
  import { useRecordings } from "./hooks/useRecordings";
41
43
  import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
42
44
  import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
43
45
  import { StandbyIcon } from "./StandbyIcon";
44
46
  import { isCameraDestroyed } from "./lifecycle";
45
- import { usePortalMenu } from "./menuPositioning";
47
+ import { claimAnchors } from "./sharedAnchors";
48
+ import { menuPortalRoot, usePortalMenu } from "./menuPositioning";
46
49
  import { formatElapsed, nowMs } from "./timing";
47
50
 
48
51
  // ---------------------------------------------------------------------------
@@ -75,6 +78,16 @@ function presetDim(operatorDim: number, scale: number): number {
75
78
  return v < 2 ? 2 : v;
76
79
  }
77
80
 
81
+ function videoTrackId(source: MediaProvider | null): string | undefined {
82
+ if (!source || !("getVideoTracks" in source)) return undefined;
83
+ return source.getVideoTracks()[0]?.id;
84
+ }
85
+
86
+ function sameVideoTrack(a: MediaProvider | null, b: MediaStream | null): boolean {
87
+ const id = videoTrackId(a);
88
+ return id !== undefined && id === videoTrackId(b);
89
+ }
90
+
78
91
  /*
79
92
  * Fullscreen helpers. Safari (incl. iPadOS) only exposes the webkit-prefixed
80
93
  * API; iOS iPhone has no element fullscreen at all, which `isFullscreenSupported`
@@ -265,8 +278,9 @@ export interface CameraFeedProps {
265
278
  enableRecording?: boolean;
266
279
  /**
267
280
  * Record this feed at the operator's full render size for the duration of
268
- * the built-in REC control's recording (overrides only the display-size
269
- * demand; still yields to the adaptive framerate shed). Off by default.
281
+ * the built-in REC control's recording (overrides the display-size
282
+ * demand and any viewer quality preset; still capped by an operator
283
+ * render-size cap and the adaptive shed). Off by default.
270
284
  * Threaded straight into `start(flightId, { forceFullResolution })`; has
271
285
  * no effect unless `enableRecording` is also set. While the feed is armed
272
286
  * (force sent, waiting for the resolution bump) the tile shows an
@@ -314,14 +328,53 @@ export interface CameraFeedProps {
314
328
  * brackets. See {@link CommandBracketOverlay}.
315
329
  */
316
330
  targetBracketOverlay?: CommandBracketOverlay;
331
+ /**
332
+ * What a command camera feed says while the camera has no subject to aim
333
+ * at yet and shows its placeholder framing (a launch site camera on its
334
+ * pad, a station looking at the horizon). Drawn over the picture, never
335
+ * into the video, so recordings stay clean. Default "Waiting for target";
336
+ * `null` or an empty string shows nothing.
337
+ */
338
+ waitingForTargetLabel?: string | null;
339
+ /**
340
+ * What the feed says while its camera has no video slot on this connection
341
+ * (`KerbcastCameraHandle.waitingForSlot`): the pool is at its ceiling or
342
+ * cannot grow. The feed takes the next slot another feed gives up. Drawn
343
+ * over the picture; `null` or an empty string shows nothing.
344
+ */
345
+ noSlotLabel?: string | null;
317
346
  /**
318
347
  * Mission UT the displayed video was captured at, for a host running its
319
348
  * own delayed playout. Picks which command-track transition is in force and
320
349
  * ages the readout. Defaults to the displayed frame's own capture UT where
321
350
  * the browser reports frame RTP timestamps, else the client's capture
322
- * clock interpolated to now.
351
+ * clock interpolated to now. While a command lock is held the brackets and
352
+ * readout come from the displayed frame's own state, which a UT alone does
353
+ * not carry: such a host passes `displayFrame` instead.
323
354
  */
324
355
  displayUt?: number;
356
+ /**
357
+ * The frame a host's own delayed playout is showing, as
358
+ * `watchDisplayedFrames` reported it when it arrived. Wins over
359
+ * `displayUt`: its `ut` picks the transition in force and its `command`
360
+ * draws the brackets and readout, so they never run ahead of the picture.
361
+ */
362
+ displayFrame?: DisplayedFrameUt | null;
363
+ /**
364
+ * Things to mark on this feed's picture: parts, craft, points on a body or
365
+ * directions. Set for the displayed camera as this client's anchors and
366
+ * cleared when the feed shows another camera or unmounts; the game
367
+ * projects each through the camera that rendered every frame, and a
368
+ * default marker is drawn where it lands. One set per camera per client:
369
+ * two feeds on the same camera share it, the last to set winning. At
370
+ * most 16. Pass a stable or memoised array; it is compared by value.
371
+ */
372
+ anchors?: Anchor[];
373
+ /**
374
+ * Restyle the anchor markers, add SVG of your own, put text under them,
375
+ * or draw anchors set in game too. See {@link AnchorOverlay}.
376
+ */
377
+ anchorOverlay?: AnchorOverlay;
325
378
  }
326
379
 
327
380
  export interface CameraFeedHandle {
@@ -362,7 +415,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
362
415
  useStream,
363
416
  enableTargetBrackets = true,
364
417
  targetBracketOverlay,
418
+ waitingForTargetLabel = "Waiting for target",
419
+ noSlotLabel = "Not streaming: too many feeds are open. Close a feed to watch this one.",
365
420
  displayUt: displayUtProp,
421
+ displayFrame,
422
+ anchors,
423
+ anchorOverlay,
366
424
  },
367
425
  ref,
368
426
  ) {
@@ -402,7 +460,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
402
460
  const effectiveShowStatic = showStatic === undefined ? !reducedMotion : showStatic;
403
461
 
404
462
  // -------------------------------------------------------------------------
405
- // Selection model (mirrors gonogo's CameraFeed)
463
+ // Selection model
406
464
  //
407
465
  // Resolution order:
408
466
  // 1. Explicit pick, if still present in the list (destroyed or not).
@@ -472,10 +530,15 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
472
530
  }, []);
473
531
  // The feed frame; fullscreen targets this and ResizeObserver measures it.
474
532
  const wrapRef = useRef<HTMLDivElement>(null);
533
+ /* Clearing to null on a switch to a camera with no stream yet stops the
534
+ previous camera's last frame lingering. A rebind can hand over a new
535
+ MediaStream around the same track; reassigning that would reset the
536
+ element and flash black, so it is skipped. */
475
537
  useEffect(() => {
476
- if (videoRef.current && stream) {
477
- videoRef.current.srcObject = stream;
478
- }
538
+ const video = videoRef.current;
539
+ if (!video || video.srcObject === stream) return;
540
+ if (sameVideoTrack(video.srcObject, stream)) return;
541
+ video.srcObject = stream;
479
542
  }, [stream]);
480
543
 
481
544
  // -------------------------------------------------------------------------
@@ -499,6 +562,20 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
499
562
  return cam.on("stall", setIsStale);
500
563
  }, [client, flightId]);
501
564
 
565
+ const [hasSource, setHasSource] = useState(false);
566
+ useEffect(() => {
567
+ if (flightId === null) {
568
+ setHasSource(false);
569
+ return;
570
+ }
571
+ const cam = client.camera(flightId);
572
+ setHasSource(cam.hasSource);
573
+ return cam.on("source", setHasSource);
574
+ }, [client, flightId]);
575
+ /* The in-stream static keeps the video element presenting frames, so a
576
+ frame count cannot tell a picture from static; this attribute can. */
577
+ const signal = flightId === null ? undefined : !hasSource ? "none" : isStale ? "stalled" : "live";
578
+
502
579
  // -------------------------------------------------------------------------
503
580
  // Fullscreen + Picture-in-Picture (opt-in, feature-detected)
504
581
  // -------------------------------------------------------------------------
@@ -540,6 +617,23 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
540
617
  };
541
618
  }, [pipAvailable, flightId]);
542
619
 
620
+ /* Browsers usually consume Escape to leave fullscreen themselves; this
621
+ covers hosts and embedded views that hand the key to the page instead. */
622
+ useEffect(() => {
623
+ if (!isFullscreen) return;
624
+ const onKey = (e: KeyboardEvent) => {
625
+ if (
626
+ e.key === "Escape" &&
627
+ !e.defaultPrevented &&
628
+ currentFullscreenElement() === wrapRef.current
629
+ ) {
630
+ exitFullscreen();
631
+ }
632
+ };
633
+ document.addEventListener("keydown", onKey);
634
+ return () => document.removeEventListener("keydown", onKey);
635
+ }, [isFullscreen]);
636
+
543
637
  const toggleFullscreen = useCallback(() => {
544
638
  const el = wrapRef.current;
545
639
  if (!el) return;
@@ -558,6 +652,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
558
652
  }, []);
559
653
 
560
654
  const isDestroyed = camera ? isCameraDestroyed(camera) : false;
655
+ const waitingForSlot = useKerbcastWaitingForSlot(flightId);
561
656
  /* Gating here rather than at the render sites covers the whole manual
562
657
  path at once: the controls unmount, the effects below zero any in-flight
563
658
  rate, and the imperative handle's pan/zoom methods no-op. */
@@ -582,16 +677,29 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
582
677
  const commandTrack = commandCamera?.commandTrack ?? null;
583
678
  const commandDisplayUt = useCommandDisplayUt(
584
679
  commandTrack !== null,
585
- displayUtProp,
680
+ displayFrame ? displayFrame.ut : displayUtProp,
586
681
  commandTrack?.events,
587
682
  commandCamera?.flightId ?? null,
588
683
  videoEl,
684
+ displayFrame?.command,
589
685
  );
590
686
  const commandState = commandTrack
591
- ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut)
687
+ ? effectiveCommandTrack(commandTrack, commandDisplayUt?.ut, commandDisplayUt?.command)
592
688
  : null;
689
+ /* This feed's anchors go to the camera it displays, and leave with it. */
690
+ const anchorsKey = anchors?.length ? JSON.stringify(anchors) : "";
691
+ useEffect(() => {
692
+ if (flightId === null || !anchorsKey) return;
693
+ return claimAnchors(client, flightId, JSON.parse(anchorsKey) as Anchor[]);
694
+ }, [client, flightId, anchorsKey]);
695
+ const drawsAnchors = !!anchorsKey || !!anchorOverlay?.owners?.length;
696
+
593
697
  const [bracketsOn, toggleBrackets] = useTargetBracketsPreference(
594
- commandCamera ? (commandCamera.commandCentre?.stationId ?? String(commandCamera.flightId)) : null,
698
+ commandCamera
699
+ ? (commandCamera.commandCentre?.siteKey || commandCamera.commandCentre?.nodeName || String(commandCamera.flightId))
700
+ : null,
701
+ // 1.11 keyed the preference by stationId; carry a saved choice over.
702
+ commandCamera?.commandCentre?.stationId ?? null,
595
703
  );
596
704
  const targetBracketsAvailable = enableTargetBrackets && isCommandFeed;
597
705
 
@@ -861,8 +969,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
861
969
  // one aims the camera at it. Hidden while a command authority holds the
862
970
  // aim lock, since only it can aim then.
863
971
  // -------------------------------------------------------------------------
864
- const commandTargets = commandCamera?.commandCentre?.targets ?? [];
865
- const commandTargetId = commandCamera?.commandCentre?.subjectPersistentId ?? 0;
972
+ /* The list as it stood at the displayed frame, so a delayed feed never offers what its picture has not reached. */
973
+ const shownCentre = commandCamera
974
+ ? client.commandCentreAt(commandCamera.flightId, commandDisplayUt?.ut)
975
+ : null;
976
+ const commandTargets = shownCentre?.targets ?? [];
977
+ const commandTargetId = shownCentre?.subjectPersistentId ?? 0;
866
978
  const commandLocked = commandCamera?.commandTrack?.authority != null;
867
979
  const commandTargetAvailable =
868
980
  isCommandFeed && !disableManualControls && !commandLocked && commandTargets.length > 0;
@@ -900,6 +1012,8 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
900
1012
  yet (see ActiveRecordingInfo.arming). Drives the ARMING pill below in
901
1013
  place of the REC badge. */
902
1014
  const armingActive = activeRecording?.arming === true;
1015
+ /* Stopped, clip not in the store yet (ActiveRecordingInfo.stopping). */
1016
+ const savingActive = activeRecording?.stopping === true;
903
1017
 
904
1018
  // Ticks once a second while recording so the elapsed timer stays live;
905
1019
  // the timer itself is derived from activeRecording.startedAt each render.
@@ -985,7 +1099,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
985
1099
  </CameraMenuItem>
986
1100
  ))}
987
1101
  </CameraMenu>,
988
- document.body,
1102
+ menuPortalRoot(),
989
1103
  )}
990
1104
 
991
1105
  {showDebugInfo &&
@@ -1175,10 +1289,14 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1175
1289
  {
1176
1290
  id: "fullscreen",
1177
1291
  label: fullscreenLabel,
1292
+ /* While fullscreen the exit stays inline, never in the ⋮ menu,
1293
+ so it can be the one control the Stage keeps visible. */
1178
1294
  stateful: false,
1295
+ inline: isFullscreen,
1179
1296
  render: () => (
1180
1297
  <OverlayIconButton
1181
1298
  type="button"
1299
+ data-kc-fullscreen-exit={isFullscreen || undefined}
1182
1300
  aria-label={fullscreenLabel}
1183
1301
  aria-pressed={isFullscreen}
1184
1302
  title={fullscreenLabel}
@@ -1259,7 +1377,13 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1259
1377
 
1260
1378
  return (
1261
1379
  <FeedBox style={feedShape}>
1262
- <Stage ref={wrapRef} $pinned={chromePinned}>
1380
+ <Stage
1381
+ ref={wrapRef}
1382
+ $pinned={chromePinned}
1383
+ $fullscreen={isFullscreen}
1384
+ data-fullscreen={isFullscreen || undefined}
1385
+ data-kc-signal={signal}
1386
+ >
1263
1387
  {flightId === null ? (
1264
1388
  <>
1265
1389
  <Empty>{emptyMessage}</Empty>
@@ -1317,7 +1441,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1317
1441
  {qualityThrottled ? " · throttled" : ""}
1318
1442
  </QualityMeta>
1319
1443
  </QualityMenu>,
1320
- document.body,
1444
+ menuPortalRoot(),
1321
1445
  )}
1322
1446
  {trackingAvailable &&
1323
1447
  trackingMenu.open &&
@@ -1350,7 +1474,7 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1350
1474
  Track target
1351
1475
  </CameraMenuItem>
1352
1476
  </QualityMenu>,
1353
- document.body,
1477
+ menuPortalRoot(),
1354
1478
  )}
1355
1479
  {commandTargetAvailable &&
1356
1480
  commandTargetMenu.open &&
@@ -1384,15 +1508,19 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1384
1508
  );
1385
1509
  })}
1386
1510
  </CameraMenu>,
1387
- document.body,
1511
+ menuPortalRoot(),
1388
1512
  )}
1389
1513
  {commandState &&
1390
1514
  !outOfFlight &&
1391
1515
  (
1392
1516
  <>
1393
1517
  {commandState.blank && <CommandBlank reason={commandState.blank} />}
1518
+ {commandState.waitingForTarget && !commandState.blank && !isDestroyed && waitingForTargetLabel && (
1519
+ <WaitingBadge role="status">{waitingForTargetLabel}</WaitingBadge>
1520
+ )}
1394
1521
  {targetBracketsAvailable && bracketsOn && (
1395
- <CommandBrackets
1522
+ <LiveCommandBrackets
1523
+ flightId={commandCamera?.flightId ?? null}
1396
1524
  state={commandState}
1397
1525
  displayUt={commandDisplayUt?.ut ?? commandTrack!.ut}
1398
1526
  utSource={commandDisplayUt?.source ?? null}
@@ -1405,6 +1533,12 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1405
1533
  )}
1406
1534
  </>
1407
1535
  )}
1536
+ {waitingForSlot && !outOfFlight && !isDestroyed && noSlotLabel && (
1537
+ <NoSlotNotice role="status">{noSlotLabel}</NoSlotNotice>
1538
+ )}
1539
+ {drawsAnchors && !outOfFlight && !isDestroyed && !commandState?.blank && (
1540
+ <LiveAnchorMarkers flightId={flightId} video={videoEl} overlay={anchorOverlay} />
1541
+ )}
1408
1542
  {outOfFlight ? (
1409
1543
  showStandbyIcon && (
1410
1544
  <StandbyOverlay role="status" aria-label="Standby, no active flight">
@@ -1437,12 +1571,17 @@ const CameraFeedInner = forwardRef<CameraFeedHandle, CameraFeedProps>(
1437
1571
  ARMING
1438
1572
  </ArmingBadge>
1439
1573
  )}
1440
- {recordingActive && !armingActive && (
1574
+ {recordingActive && !armingActive && !savingActive && (
1441
1575
  <RecBadge role="status" aria-label="Recording">
1442
1576
  <RecDot aria-hidden="true" />
1443
1577
  REC {formatElapsed(recordingElapsedMs)}
1444
1578
  </RecBadge>
1445
1579
  )}
1580
+ {recordingActive && savingActive && (
1581
+ <RecBadge role="status" aria-label="Saving recording">
1582
+ SAVING
1583
+ </RecBadge>
1584
+ )}
1446
1585
  {showZoom && (
1447
1586
  <ZoomControlsWrap $disabled={manualDisabled} aria-hidden={manualDisabled}>
1448
1587
  <ZoomButton
@@ -1588,7 +1727,7 @@ export const CameraFeed = forwardRef<CameraFeedHandle, CameraFeedProps>(
1588
1727
  );
1589
1728
 
1590
1729
  // ---------------------------------------------------------------------------
1591
- // Inline UI primitives (replacing @gonogo/ui dependencies)
1730
+ // Inline UI primitives
1592
1731
  // ---------------------------------------------------------------------------
1593
1732
 
1594
1733
  function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
@@ -1944,8 +2083,9 @@ const StepButtons = styled.div`
1944
2083
  `;
1945
2084
 
1946
2085
  const CameraMenu = styled.div`
1947
- /* Portaled to document.body: fixed position (set inline from the trigger
1948
- rect) keeps it clear of the tile's overflow clipping. */
2086
+ /* Portaled to document.body (the fullscreen element while fullscreen):
2087
+ fixed position (set inline from the trigger rect) keeps it clear of the
2088
+ tile's overflow clipping. */
1949
2089
  position: fixed;
1950
2090
  z-index: 1000;
1951
2091
  max-width: min(260px, calc(100vw - 16px));
@@ -2005,7 +2145,7 @@ const TopMeta = styled.div`
2005
2145
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
2006
2146
  `;
2007
2147
 
2008
- /* The quality picker: portaled to document.body like CameraMenu, hung from
2148
+ /* The quality picker: portaled like CameraMenu, hung from
2009
2149
  its action-bar trigger (fixed position set inline from the trigger rect)
2010
2150
  so tile clipping cannot cut it off and it survives the hover-revealed
2011
2151
  chrome fading while the pointer is over the menu. */
@@ -2191,7 +2331,7 @@ const FeedBox = styled.div`
2191
2331
  justify-content: center;
2192
2332
  `;
2193
2333
 
2194
- const Stage = styled(Panel)<{ $pinned: boolean }>`
2334
+ const Stage = styled(Panel)<{ $pinned: boolean; $fullscreen: boolean }>`
2195
2335
  width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
2196
2336
  height: auto;
2197
2337
  aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
@@ -2236,6 +2376,31 @@ const Stage = styled(Panel)<{ $pinned: boolean }>`
2236
2376
  opacity: 1;
2237
2377
  }
2238
2378
  `}
2379
+
2380
+ /* Fullscreen has no hover on touch and may have no pointer at all, so the
2381
+ exit button stays visible in its usual slot while the rest of the bar
2382
+ keeps its hover/focus/tap reveal. */
2383
+ ${(p) =>
2384
+ p.$fullscreen &&
2385
+ !p.$pinned &&
2386
+ css`
2387
+ ${ActionBarRow} {
2388
+ opacity: 1;
2389
+ pointer-events: auto;
2390
+ }
2391
+ ${ActionBarRow} > * {
2392
+ transition: opacity 0.15s;
2393
+ }
2394
+ &:not(:hover):not(:focus-within) ${ActionBarRow} > :not([data-kc-fullscreen-exit]) {
2395
+ opacity: 0;
2396
+ pointer-events: none;
2397
+ }
2398
+ @media (prefers-reduced-motion: reduce) {
2399
+ ${ActionBarRow} > * {
2400
+ transition: none;
2401
+ }
2402
+ }
2403
+ `}
2239
2404
  `;
2240
2405
 
2241
2406
  const Empty = styled.div`
@@ -2326,6 +2491,54 @@ const StaleScrim = styled.div`
2326
2491
  pointer-events: none;
2327
2492
  `;
2328
2493
 
2494
+ /* Centred in the picture, clear of the chrome along the edges, and wrapping so
2495
+ a small tile still shows the whole sentence. */
2496
+ const NoSlotNotice = styled.div`
2497
+ position: absolute;
2498
+ top: 50%;
2499
+ left: 50%;
2500
+ transform: translate(-50%, -50%);
2501
+ z-index: 2;
2502
+ max-width: calc(100% - 32px);
2503
+ padding: 6px 10px;
2504
+ background: rgba(0, 0, 0, 0.6);
2505
+ border: 1px solid rgba(255, 255, 255, 0.4);
2506
+ border-radius: 3px;
2507
+ color: rgba(255, 255, 255, 0.9);
2508
+ font-size: 12px;
2509
+ line-height: 1.4;
2510
+ text-align: center;
2511
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
2512
+ pointer-events: none;
2513
+ `;
2514
+
2515
+ /* Bottom centre: the zoom controls take the bottom left, pan and the stale
2516
+ badge the bottom right, the title and REC badge the top left and the
2517
+ action bar the top right. */
2518
+ const WaitingBadge = styled.div`
2519
+ position: absolute;
2520
+ bottom: 8px;
2521
+ left: 50%;
2522
+ transform: translateX(-50%);
2523
+ z-index: 2;
2524
+ /* Clear of the stale badge on the bottom right, even on a small tile. */
2525
+ max-width: calc(100% - 180px);
2526
+ overflow: hidden;
2527
+ text-overflow: ellipsis;
2528
+ white-space: nowrap;
2529
+ padding: 2px 6px;
2530
+ background: rgba(0, 0, 0, 0.6);
2531
+ border: 1px solid rgba(255, 255, 255, 0.4);
2532
+ border-radius: 3px;
2533
+ color: rgba(255, 255, 255, 0.9);
2534
+ font-size: 10px;
2535
+ font-weight: 600;
2536
+ letter-spacing: 0.12em;
2537
+ text-transform: uppercase;
2538
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
2539
+ pointer-events: none;
2540
+ `;
2541
+
2329
2542
  const StaleBadge = styled.div`
2330
2543
  position: absolute;
2331
2544
  bottom: 8px;