UpdatedStreamOverlay.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref, type RefObject, type SyntheticEvent } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Clock, Layers, Printer, Timer } from 'lucide-react';
  4. import './UpdatedStreamOverlay.css';
  5. import { overlayProgressTextStyle } from '../utils/overlayBranding';
  6. import type { OverlayLayout } from '../utils/overlayLayout';
  7. interface UpdatedStreamOverlayProps {
  8. layout?: OverlayLayout;
  9. backgroundTransparency?: number;
  10. customLogo?: ReactNode;
  11. progressBackground?: string;
  12. size: 'small' | 'medium' | 'large';
  13. // url is undefined while the stream is closed for a renewal (useOverlayCameraRecovery).
  14. camera: {
  15. url: string | undefined;
  16. rotation: number;
  17. onError: (event: SyntheticEvent<HTMLImageElement>) => void;
  18. imgRef?: Ref<HTMLImageElement>;
  19. } | null;
  20. name: string | null;
  21. model: string | null;
  22. filename: string | null;
  23. status: string | null;
  24. state: string | null;
  25. progress: number | null;
  26. layers: string | null;
  27. remaining: string | null;
  28. eta: string | null;
  29. temperatures: { key: string; icon: ReactNode; label: string; current: number; target: number | null }[];
  30. }
  31. // The camera slot's size in px, measured only while the camera is turned
  32. // sideways. Measured rather than written as 100cqh/100cqw because container
  33. // units need Chromium 105+, which older OBS browser sources don't have, and
  34. // the viewport units they would fall back to are wrong for the portrait
  35. // layout, where the camera fills only the middle row.
  36. function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
  37. const [box, setBox] = useState<{ width: number; height: number } | null>(null);
  38. // Layout effect: measured before paint, so a sideways camera never shows a
  39. // frame at the unswapped size.
  40. useLayoutEffect(() => {
  41. const el = ref.current;
  42. if (!active || !el) return;
  43. const measure = () => {
  44. // Layout dimensions exclude the scale applied by OverlayFrame.
  45. const width = el.clientWidth;
  46. const height = el.clientHeight;
  47. if (width > 0 && height > 0) {
  48. setBox((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height }));
  49. }
  50. };
  51. measure();
  52. if (typeof ResizeObserver === 'undefined') {
  53. window.addEventListener('resize', measure);
  54. return () => window.removeEventListener('resize', measure);
  55. }
  56. const observer = new ResizeObserver(measure);
  57. observer.observe(el);
  58. return () => observer.disconnect();
  59. }, [ref, active]);
  60. return active ? box : null;
  61. }
  62. // Explicit sources keep their orientation; legacy URLs follow the viewport.
  63. function useOverlayOrientation(layout?: OverlayLayout): OverlayLayout {
  64. const [portrait, setPortrait] = useState(() => window.matchMedia('(orientation: portrait)').matches);
  65. useEffect(() => {
  66. if (layout) return;
  67. const media = window.matchMedia('(orientation: portrait)');
  68. const update = (event: MediaQueryListEvent) => setPortrait(event.matches);
  69. setPortrait(media.matches);
  70. media.addEventListener('change', update);
  71. return () => media.removeEventListener('change', update);
  72. }, [layout]);
  73. return layout ?? (portrait ? 'portrait' : 'landscape');
  74. }
  75. export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
  76. const { t } = useTranslation();
  77. const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
  78. const backgroundStyle: CSSProperties & {
  79. '--overlay-background-alpha': number;
  80. '--overlay-identity-alpha': number;
  81. '--overlay-panel-alpha': number;
  82. } = {
  83. '--overlay-background-alpha': backgroundAlpha,
  84. '--overlay-identity-alpha': backgroundAlpha * 0.9,
  85. '--overlay-panel-alpha': backgroundAlpha * 0.88,
  86. };
  87. useLayoutEffect(() => {
  88. if (backgroundAlpha === 1) return;
  89. // The app's body background would otherwise fill every transparent pixel
  90. // in OBS. Restore it when leaving the overlay or resetting transparency.
  91. const previous = document.body.style.backgroundColor;
  92. document.body.style.backgroundColor = 'transparent';
  93. return () => { document.body.style.backgroundColor = previous; };
  94. }, [backgroundAlpha]);
  95. const layout = useOverlayOrientation(props.layout);
  96. const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
  97. props;
  98. const progressTextStyle = overlayProgressTextStyle(props.progressBackground);
  99. const hasPanel =
  100. filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
  101. const rotation = camera?.rotation ?? 0;
  102. const sideways = Math.abs(rotation % 180) === 90;
  103. const cameraRef = useRef<HTMLDivElement>(null);
  104. const cameraBox = useCameraBox(cameraRef, sideways && camera != null);
  105. const stats = [
  106. { key: 'layers', icon: <Layers />, label: t('streamOverlay.layer'), value: layers },
  107. { key: 'remaining', icon: <Timer />, label: t('streamOverlay.remaining'), value: remaining },
  108. { key: 'eta', icon: <Clock />, label: t('streamOverlay.eta'), value: eta },
  109. ].filter((stat) => stat.value != null);
  110. return (
  111. <div className="updated-overlay" data-size={props.size} data-layout={layout} data-state={state} style={backgroundStyle}>
  112. <header className="updated-overlay__header">
  113. {(name || model) && (
  114. <div className="updated-overlay__identity">
  115. <Printer aria-hidden="true" />
  116. <div>
  117. {name && <strong>{name}</strong>}
  118. {model && <span>{model}</span>}
  119. </div>
  120. </div>
  121. )}
  122. <div className="updated-overlay__logo flex flex-col items-end">
  123. {props.customLogo}
  124. <a
  125. href="https://github.com/maziggy/bambuddy"
  126. target="_blank"
  127. rel="noopener noreferrer"
  128. >
  129. <img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
  130. </a>
  131. </div>
  132. </header>
  133. {camera && (
  134. <div className="updated-overlay__camera" ref={cameraRef}>
  135. <img
  136. ref={camera.imgRef}
  137. src={camera.url}
  138. alt={t('streamOverlay.cameraStream')}
  139. style={{
  140. transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
  141. // A quarter turn swaps the image's axes, so give it the slot's
  142. // height as width and vice versa; cover then still fills the slot.
  143. ...(sideways && cameraBox ? { width: cameraBox.height, height: cameraBox.width } : {}),
  144. }}
  145. onError={camera.onError}
  146. />
  147. </div>
  148. )}
  149. {hasPanel && (
  150. <section className="updated-overlay__panel">
  151. {(filename || status) && (
  152. <div className="updated-overlay__summary">
  153. {filename && <h1>{filename}</h1>}
  154. {status && (
  155. <div className="updated-overlay__status">
  156. <span aria-hidden="true" />
  157. {status}
  158. </div>
  159. )}
  160. </div>
  161. )}
  162. {progress != null && (
  163. <div className="updated-overlay__progress">
  164. <span style={progressTextStyle}>{t('streamOverlay.progress')}</span>
  165. <div
  166. role="progressbar"
  167. aria-label={t('streamOverlay.progress')}
  168. aria-valuemin={0}
  169. aria-valuemax={100}
  170. aria-valuenow={progress}
  171. >
  172. <div style={{ width: `${progress}%`, background: props.progressBackground }} />
  173. </div>
  174. <strong style={progressTextStyle}>{Math.round(progress)}%</strong>
  175. </div>
  176. )}
  177. {(stats.length > 0 || temperatures.length > 0) && (
  178. <div className="updated-overlay__readings">
  179. {stats.length > 0 && (
  180. <div className="updated-overlay__stats">
  181. {stats.map((stat) => (
  182. <div className="updated-overlay__reading" key={stat.key}>
  183. {stat.icon}
  184. <div>
  185. <span>{stat.label}</span>
  186. <strong>{stat.value}</strong>
  187. </div>
  188. </div>
  189. ))}
  190. </div>
  191. )}
  192. {temperatures.length > 0 && (
  193. <div className="updated-overlay__temperatures">
  194. {temperatures.map((reading) => (
  195. <div className="updated-overlay__reading" key={reading.key}>
  196. {reading.icon}
  197. <div>
  198. <span>{reading.label}</span>
  199. <strong>
  200. {Math.round(reading.current)}°C
  201. {reading.target != null &&
  202. reading.target > 0 &&
  203. Math.round(reading.target) !== Math.round(reading.current) && (
  204. <small> / {Math.round(reading.target)}°C</small>
  205. )}
  206. </strong>
  207. </div>
  208. </div>
  209. ))}
  210. </div>
  211. )}
  212. </div>
  213. )}
  214. </section>
  215. )}
  216. </div>
  217. );
  218. }