CamWallPage.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * Standalone Cam Wall at ``/camwall`` (#2531).
  3. *
  4. * Two ways in, and they authenticate differently:
  5. *
  6. * - **Signed in.** Same wall the Printers page shows, on a URL you can
  7. * bookmark. Data comes from the ordinary printers API behind the session
  8. * JWT, and tiles stay clickable.
  9. *
  10. * - **``?token=<camwall token>``.** For a screen with no login — a shop TV, a
  11. * Raspberry Pi in kiosk mode. The token authenticates both the tile feed and
  12. * the video, and the wall drops to what a passive display needs: no settings
  13. * popover, no click-through, and the compact status overlay only. A URL taped
  14. * to a wall display is not a secret, so the page behind it shows a printer's
  15. * name and state and nothing else — never the filename of the part on the bed.
  16. *
  17. * There is no WebSocket in either mode: this page renders outside the app
  18. * layout and its provider, so statuses are polled. A wall is watched, not
  19. * operated — a few seconds of latency costs nothing, and a kiosk token cannot
  20. * mint the WS ticket anyway.
  21. */
  22. import { useEffect, useMemo, useState } from 'react';
  23. import { Navigate, useLocation, useSearchParams } from 'react-router-dom';
  24. import { useQuery } from '@tanstack/react-query';
  25. import { useTranslation } from 'react-i18next';
  26. import { CameraWall, type CameraWallStatus } from '../components/CameraWall';
  27. import { CAM_WALL_TILE_SIZES, DEFAULT_CAM_WALL_TILE_SIZE } from '../components/camWallTileSize';
  28. import { type CameraTileStatusMode } from '../components/CameraTile';
  29. import { api, setStreamToken } from '../api/client';
  30. import { useAuth } from '../contexts/AuthContext';
  31. import { AuthRetrying } from '../components/AuthRetrying';
  32. // Kiosk polling cadence. Matches the staleTime the in-page wall runs at, so a
  33. // tile's chip is never more stale than it would be on the Printers page.
  34. const KIOSK_POLL_MS = 5000;
  35. const DEFAULT_MAX_LIVE = 4;
  36. const DEFAULT_SNAPSHOT_SEC = 8;
  37. const MIN_MAX_LIVE = 1;
  38. const MAX_MAX_LIVE = 16;
  39. const MIN_SNAPSHOT_SEC = 2;
  40. const MAX_SNAPSHOT_SEC = 60;
  41. function clampInt(raw: string | null, fallback: number, min: number, max: number): number {
  42. const n = parseInt(raw ?? '', 10);
  43. if (!Number.isFinite(n)) return fallback;
  44. return Math.min(max, Math.max(min, n));
  45. }
  46. /** URL wins, then the knob the user last set on the Printers page, then the default. */
  47. function fromUrlOrStorage(
  48. params: URLSearchParams,
  49. urlKey: string,
  50. storageKey: string,
  51. fallback: number,
  52. min: number,
  53. max: number,
  54. ): number {
  55. if (params.has(urlKey)) return clampInt(params.get(urlKey), fallback, min, max);
  56. return clampInt(localStorage.getItem(storageKey), fallback, min, max);
  57. }
  58. /** ``?size=s|m|l|xl`` (#2735), else the size last picked on the Printers page. */
  59. function tileSizeFrom(params: URLSearchParams): number {
  60. const requested = params.get('size')?.toLowerCase();
  61. const fromUrl = CAM_WALL_TILE_SIZES.indexOf(requested as (typeof CAM_WALL_TILE_SIZES)[number]);
  62. if (fromUrl >= 0) return fromUrl + 1;
  63. const saved = parseInt(localStorage.getItem('camWallTileSize') ?? '', 10);
  64. return saved >= 1 && saved <= 4 ? saved : DEFAULT_CAM_WALL_TILE_SIZE;
  65. }
  66. export function CamWallPage() {
  67. const { t } = useTranslation();
  68. const location = useLocation();
  69. const { authEnabled, loading: authLoading, user, authUnconfirmed } = useAuth();
  70. const [searchParams] = useSearchParams();
  71. const token = searchParams.get('token');
  72. const kiosk = token != null && token !== '';
  73. // Seeded from the URL, then from whatever the user last chose on the Printers
  74. // page, then the defaults. Held as state so the settings popover on a
  75. // signed-in wall actually moves them; a kiosk wall never shows the popover.
  76. const [maxLive, setMaxLive] = useState(() =>
  77. fromUrlOrStorage(searchParams, 'maxLive', 'camWallMaxLive', DEFAULT_MAX_LIVE, MIN_MAX_LIVE, MAX_MAX_LIVE),
  78. );
  79. const [snapshotIntervalSec, setSnapshotIntervalSec] = useState(() =>
  80. fromUrlOrStorage(
  81. searchParams,
  82. 'interval',
  83. 'camWallSnapshotSec',
  84. DEFAULT_SNAPSHOT_SEC,
  85. MIN_SNAPSHOT_SEC,
  86. MAX_SNAPSHOT_SEC,
  87. ),
  88. );
  89. const [tileSize] = useState(() => tileSizeFrom(searchParams));
  90. const [statusMode, setStatusMode] = useState<CameraTileStatusMode>(() => {
  91. const requested = searchParams.get('status') ?? localStorage.getItem('camWallStatusMode');
  92. // 'full' names the file on the bed. Fine on a signed-in screen, wrong on one
  93. // anybody can walk past — so a kiosk wall caps at 'compact', and the feed
  94. // behind it declines to serve the filename in the first place.
  95. const allowed: CameraTileStatusMode[] = kiosk ? ['off', 'compact'] : ['off', 'compact', 'full'];
  96. return allowed.includes(requested as CameraTileStatusMode)
  97. ? (requested as CameraTileStatusMode)
  98. : 'compact';
  99. });
  100. // The MJPEG <img> tags cannot send an Authorization header, so they carry the
  101. // token in the query string. Handing it to the client module means CameraTile
  102. // picks it up through the same withStreamToken() path a signed-in wall uses.
  103. // Safe against the app-wide stream-token sync: that query is disabled while
  104. // no user is signed in, which is precisely the kiosk case.
  105. useEffect(() => {
  106. if (!kiosk) return;
  107. setStreamToken(token);
  108. return () => setStreamToken(null);
  109. }, [kiosk, token]);
  110. const kioskQuery = useQuery({
  111. queryKey: ['camwall-printers', token],
  112. queryFn: () => api.getCamWallPrinters(token ?? undefined),
  113. enabled: kiosk,
  114. refetchInterval: KIOSK_POLL_MS,
  115. });
  116. // Signed-in mode: same source as the Printers page, so the wall and the cards
  117. // agree. CameraWall fetches the per-printer statuses itself from here.
  118. const printersQuery = useQuery({
  119. queryKey: ['printers'],
  120. queryFn: () => api.getPrinters(),
  121. enabled: !kiosk && !authLoading && (!authEnabled || user !== null),
  122. refetchInterval: KIOSK_POLL_MS,
  123. });
  124. const kioskStatuses = useMemo(() => {
  125. const map = new Map<number, CameraWallStatus | undefined>();
  126. for (const p of kioskQuery.data ?? []) {
  127. map.set(p.id, {
  128. connected: p.connected,
  129. state: p.state,
  130. progress: p.progress,
  131. remaining_time: p.remaining_time,
  132. layer_num: p.layer_num,
  133. total_layers: p.total_layers,
  134. hms_errors: p.hms_errors,
  135. });
  136. }
  137. return map;
  138. }, [kioskQuery.data]);
  139. if (!kiosk && authLoading) {
  140. return (
  141. <div className="flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray">
  142. {t('common.loading')}
  143. </div>
  144. );
  145. }
  146. if (!kiosk && authUnconfirmed) {
  147. return <AuthRetrying />;
  148. }
  149. // No token and no session: this is just a normal page of the app.
  150. if (!kiosk && authEnabled && !user) {
  151. return <Navigate to="/login" replace state={{ from: location }} />;
  152. }
  153. const query = kiosk ? kioskQuery : printersQuery;
  154. const printers = kiosk ? (kioskQuery.data ?? []) : (printersQuery.data ?? []);
  155. if (query.isError) {
  156. return (
  157. <div className="flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center">
  158. <p className="max-w-md text-sm text-red-400">
  159. {kiosk ? t('printers.camWall.page.tokenRejected') : t('printers.camWall.page.loadFailed')}
  160. </p>
  161. </div>
  162. );
  163. }
  164. return (
  165. <div className="min-h-screen bg-bambu-dark p-4">
  166. <CameraWall
  167. printers={printers}
  168. maxLive={maxLive}
  169. snapshotIntervalSec={snapshotIntervalSec}
  170. statusMode={statusMode}
  171. tileSize={tileSize}
  172. statuses={kiosk ? kioskStatuses : undefined}
  173. showSettings={!kiosk}
  174. onTileClick={kiosk ? undefined : (id) => window.open(`/camera/${id}`, `camera-${id}`)}
  175. // Writes to the same localStorage keys the Printers page reads, so a
  176. // change made here follows the user back there. A kiosk wall hides the
  177. // popover, so these never fire.
  178. onChangeMaxLive={(next) => {
  179. setMaxLive(next);
  180. localStorage.setItem('camWallMaxLive', String(next));
  181. }}
  182. onChangeSnapshotIntervalSec={(next) => {
  183. setSnapshotIntervalSec(next);
  184. localStorage.setItem('camWallSnapshotSec', String(next));
  185. }}
  186. onChangeStatusMode={(next) => {
  187. setStatusMode(next);
  188. localStorage.setItem('camWallStatusMode', next);
  189. }}
  190. />
  191. </div>
  192. );
  193. }
  194. export default CamWallPage;