CamWallPage.tsx 8.1 KB

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