CameraTile.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { VideoOff, WifiOff } from 'lucide-react';
  4. import { getAuthToken, withStreamToken } from '../api/client';
  5. export type CameraTileMode = 'live' | 'snapshot' | 'paused';
  6. interface CameraTileProps {
  7. printerId: number;
  8. printerName: string;
  9. cameraRotation?: number;
  10. mode: CameraTileMode;
  11. snapshotIntervalMs: number;
  12. connected: boolean;
  13. onClick?: () => void;
  14. }
  15. // Tiles render lighter than EmbeddedCameraViewer's full window: lower fps,
  16. // no drag/resize/zoom shell, and snapshot fallback when off-cap. The server
  17. // still does the MJPEG fan-out, so per-tile cost is one TLS pull on the wire.
  18. const LIVE_FPS = 8;
  19. export function CameraTile({
  20. printerId,
  21. printerName,
  22. cameraRotation = 0,
  23. mode,
  24. snapshotIntervalMs,
  25. connected,
  26. onClick,
  27. }: CameraTileProps) {
  28. const { t } = useTranslation();
  29. const [bust, setBust] = useState(0);
  30. const [errored, setErrored] = useState(false);
  31. const lastModeRef = useRef<CameraTileMode>(mode);
  32. // Tell the backend to release its MJPEG transcoder when this tile stops
  33. // being live — either by unmounting or by transitioning to snapshot/paused.
  34. // EmbeddedCameraViewer uses the same /camera/stop with keepalive on unmount.
  35. useEffect(() => {
  36. const wasLive = lastModeRef.current === 'live';
  37. const isLive = mode === 'live';
  38. lastModeRef.current = mode;
  39. if (wasLive && !isLive) {
  40. const headers: Record<string, string> = {};
  41. const token = getAuthToken();
  42. if (token) headers['Authorization'] = `Bearer ${token}`;
  43. fetch(`/api/v1/printers/${printerId}/camera/stop`, {
  44. method: 'POST',
  45. keepalive: true,
  46. headers,
  47. }).catch(() => {});
  48. }
  49. setErrored(false);
  50. setBust((b) => b + 1);
  51. }, [mode, printerId]);
  52. useEffect(() => {
  53. return () => {
  54. if (lastModeRef.current === 'live') {
  55. const headers: Record<string, string> = {};
  56. const token = getAuthToken();
  57. if (token) headers['Authorization'] = `Bearer ${token}`;
  58. fetch(`/api/v1/printers/${printerId}/camera/stop`, {
  59. method: 'POST',
  60. keepalive: true,
  61. headers,
  62. }).catch(() => {});
  63. }
  64. };
  65. }, [printerId]);
  66. useEffect(() => {
  67. if (mode !== 'snapshot') return;
  68. const interval = setInterval(() => setBust((b) => b + 1), snapshotIntervalMs);
  69. return () => clearInterval(interval);
  70. }, [mode, snapshotIntervalMs]);
  71. const liveUrl = withStreamToken(
  72. `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
  73. );
  74. const snapshotUrl = withStreamToken(
  75. `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,
  76. );
  77. const handleClick = () => {
  78. if (onClick) onClick();
  79. };
  80. const transform = cameraRotation ? `rotate(${cameraRotation}deg)` : undefined;
  81. return (
  82. <button
  83. type="button"
  84. onClick={handleClick}
  85. className="group relative aspect-video w-full overflow-hidden rounded-lg border border-bambu-dark-tertiary bg-black text-left focus:outline-none focus:ring-2 focus:ring-bambu-green"
  86. title={printerName}
  87. >
  88. {!connected || mode === 'paused' ? (
  89. <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/60">
  90. {connected ? (
  91. <VideoOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  92. ) : (
  93. <WifiOff className="h-8 w-8 text-bambu-gray/70" aria-hidden="true" />
  94. )}
  95. </div>
  96. ) : errored ? (
  97. <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/80 text-bambu-gray">
  98. <VideoOff className="h-7 w-7" aria-hidden="true" />
  99. <span className="text-xs">{t('printers.camWall.noSignal')}</span>
  100. </div>
  101. ) : (
  102. <img
  103. key={`${mode}-${bust}`}
  104. src={mode === 'live' ? liveUrl : snapshotUrl}
  105. alt={printerName}
  106. draggable={false}
  107. loading="lazy"
  108. className="h-full w-full select-none object-contain"
  109. style={{ transform }}
  110. onError={() => setErrored(true)}
  111. />
  112. )}
  113. {/* Mode indicator */}
  114. <span
  115. className={`absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
  116. mode === 'live'
  117. ? 'bg-red-500/80 text-white'
  118. : mode === 'snapshot'
  119. ? 'bg-amber-500/70 text-black'
  120. : 'bg-bambu-dark-tertiary/70 text-bambu-gray'
  121. }`}
  122. >
  123. {mode === 'live'
  124. ? t('printers.camWall.live')
  125. : mode === 'snapshot'
  126. ? t('printers.camWall.snap')
  127. : t('printers.camWall.off')}
  128. </span>
  129. {/* Name overlay */}
  130. <span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1.5 pt-3 text-xs font-medium text-white">
  131. {printerName}
  132. </span>
  133. </button>
  134. );
  135. }