UpdatedStreamOverlay.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type MutableRefObject, type ReactNode, 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 { useSidewaysBox } from '../hooks/useSidewaysBox';
  7. import { useStreamImgRef } from '../hooks/useStreamImgRef';
  8. import type { OverlayLayout } from '../utils/overlayLayout';
  9. interface UpdatedStreamOverlayProps {
  10. layout?: OverlayLayout;
  11. backgroundTransparency?: number;
  12. customLogo?: ReactNode;
  13. progressBackground?: string;
  14. size: 'small' | 'medium' | 'large';
  15. // url is undefined while the stream is closed for a renewal (useOverlayCameraRecovery).
  16. camera: {
  17. url: string | undefined;
  18. rotation: number;
  19. onError: (event: SyntheticEvent<HTMLImageElement>) => void;
  20. imgRef?: MutableRefObject<HTMLImageElement | null>;
  21. } | null;
  22. name: string | null;
  23. model: string | null;
  24. filename: string | null;
  25. status: string | null;
  26. state: string | null;
  27. progress: number | null;
  28. layers: string | null;
  29. remaining: string | null;
  30. eta: string | null;
  31. temperatures: { key: string; icon: ReactNode; label: string; current: number; target: number | null }[];
  32. }
  33. // Explicit sources keep their orientation; legacy URLs follow the viewport.
  34. function useOverlayOrientation(layout?: OverlayLayout): OverlayLayout {
  35. const [portrait, setPortrait] = useState(() => window.matchMedia('(orientation: portrait)').matches);
  36. useEffect(() => {
  37. if (layout) return;
  38. const media = window.matchMedia('(orientation: portrait)');
  39. const update = (event: MediaQueryListEvent) => setPortrait(event.matches);
  40. setPortrait(media.matches);
  41. media.addEventListener('change', update);
  42. return () => media.removeEventListener('change', update);
  43. }, [layout]);
  44. return layout ?? (portrait ? 'portrait' : 'landscape');
  45. }
  46. export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
  47. const { t } = useTranslation();
  48. // The camera img gets its src from here, at commit, never from a prop (#3431).
  49. const ownImgRef = useRef<HTMLImageElement | null>(null);
  50. const attachCamera = useStreamImgRef(props.camera?.imgRef ?? ownImgRef, props.camera?.url);
  51. const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
  52. const backgroundStyle: CSSProperties & {
  53. '--overlay-background-alpha': number;
  54. '--overlay-identity-alpha': number;
  55. '--overlay-panel-alpha': number;
  56. } = {
  57. '--overlay-background-alpha': backgroundAlpha,
  58. '--overlay-identity-alpha': backgroundAlpha * 0.9,
  59. '--overlay-panel-alpha': backgroundAlpha * 0.88,
  60. };
  61. useLayoutEffect(() => {
  62. if (backgroundAlpha === 1) return;
  63. // The app's body background would otherwise fill every transparent pixel
  64. // in OBS. Restore it when leaving the overlay or resetting transparency.
  65. const previous = document.body.style.backgroundColor;
  66. document.body.style.backgroundColor = 'transparent';
  67. return () => { document.body.style.backgroundColor = previous; };
  68. }, [backgroundAlpha]);
  69. const layout = useOverlayOrientation(props.layout);
  70. const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
  71. props;
  72. const progressTextStyle = overlayProgressTextStyle(props.progressBackground);
  73. const hasPanel =
  74. filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
  75. const rotation = camera?.rotation ?? 0;
  76. const sideways = Math.abs(rotation % 180) === 90;
  77. const cameraRef = useRef<HTMLDivElement>(null);
  78. const cameraBox = useSidewaysBox(cameraRef, sideways && camera != null);
  79. const stats = [
  80. { key: 'layers', icon: <Layers />, label: t('streamOverlay.layer'), value: layers },
  81. { key: 'remaining', icon: <Timer />, label: t('streamOverlay.remaining'), value: remaining },
  82. { key: 'eta', icon: <Clock />, label: t('streamOverlay.eta'), value: eta },
  83. ].filter((stat) => stat.value != null);
  84. return (
  85. <div className="updated-overlay" data-size={props.size} data-layout={layout} data-state={state} style={backgroundStyle}>
  86. <header className="updated-overlay__header">
  87. {(name || model) && (
  88. <div className="updated-overlay__identity">
  89. <Printer aria-hidden="true" />
  90. <div>
  91. {name && <strong>{name}</strong>}
  92. {model && <span>{model}</span>}
  93. </div>
  94. </div>
  95. )}
  96. <div className="updated-overlay__logo flex flex-col items-end">
  97. {props.customLogo}
  98. <a
  99. href="https://github.com/maziggy/bambuddy"
  100. target="_blank"
  101. rel="noopener noreferrer"
  102. >
  103. <img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
  104. </a>
  105. </div>
  106. </header>
  107. {camera && (
  108. <div className="updated-overlay__camera" ref={cameraRef}>
  109. <img
  110. ref={attachCamera}
  111. alt={t('streamOverlay.cameraStream')}
  112. style={{
  113. transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
  114. // A quarter turn swaps the image's axes, so give it the slot's
  115. // height as width and vice versa; cover then still fills the slot.
  116. ...(sideways && cameraBox ? { width: cameraBox.height, height: cameraBox.width } : {}),
  117. }}
  118. onError={camera.onError}
  119. />
  120. </div>
  121. )}
  122. {hasPanel && (
  123. <section className="updated-overlay__panel">
  124. {(filename || status) && (
  125. <div className="updated-overlay__summary">
  126. {filename && <h1>{filename}</h1>}
  127. {status && (
  128. <div className="updated-overlay__status">
  129. <span aria-hidden="true" />
  130. {status}
  131. </div>
  132. )}
  133. </div>
  134. )}
  135. {progress != null && (
  136. <div className="updated-overlay__progress">
  137. <span style={progressTextStyle}>{t('streamOverlay.progress')}</span>
  138. <div
  139. role="progressbar"
  140. aria-label={t('streamOverlay.progress')}
  141. aria-valuemin={0}
  142. aria-valuemax={100}
  143. aria-valuenow={progress}
  144. >
  145. <div style={{ width: `${progress}%`, background: props.progressBackground }} />
  146. </div>
  147. <strong style={progressTextStyle}>{Math.round(progress)}%</strong>
  148. </div>
  149. )}
  150. {(stats.length > 0 || temperatures.length > 0) && (
  151. <div className="updated-overlay__readings">
  152. {stats.length > 0 && (
  153. <div className="updated-overlay__stats">
  154. {stats.map((stat) => (
  155. <div className="updated-overlay__reading" key={stat.key}>
  156. {stat.icon}
  157. <div>
  158. <span>{stat.label}</span>
  159. <strong>{stat.value}</strong>
  160. </div>
  161. </div>
  162. ))}
  163. </div>
  164. )}
  165. {temperatures.length > 0 && (
  166. <div className="updated-overlay__temperatures">
  167. {temperatures.map((reading) => (
  168. <div className="updated-overlay__reading" key={reading.key}>
  169. {reading.icon}
  170. <div>
  171. <span>{reading.label}</span>
  172. <strong>
  173. {Math.round(reading.current)}°C
  174. {reading.target != null &&
  175. reading.target > 0 &&
  176. Math.round(reading.target) !== Math.round(reading.current) && (
  177. <small> / {Math.round(reading.target)}°C</small>
  178. )}
  179. </strong>
  180. </div>
  181. </div>
  182. ))}
  183. </div>
  184. )}
  185. </div>
  186. )}
  187. </section>
  188. )}
  189. </div>
  190. );
  191. }