@stream-io/video-react-sdk 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -827,6 +827,34 @@ const AvatarFallback = ({ className, names, style, }) => {
827
827
  return (jsxRuntime.jsx("div", { className: clsx('str-video__avatar--initials-fallback', className), style: style, children: jsxRuntime.jsxs("div", { children: [names[0][0], names[1]?.[0]] }) }));
828
828
  };
829
829
 
830
+ /**
831
+ * Constants for FPS warning calculation.
832
+ * Smooths out quick spikes using an EMA, ignores brief outliers,
833
+ * and uses two thresholds to avoid flickering near the limit.
834
+ */
835
+ const ALPHA = 0.2;
836
+ const FPS_WARNING_THRESHOLD_LOWER = 23;
837
+ const FPS_WARNING_THRESHOLD_UPPER = 25;
838
+ const DEFAULT_FPS = 30;
839
+ const DEVIATION_LIMIT = 0.5;
840
+ const OUTLIER_PERSISTENCE = 5;
841
+ /**
842
+ * Represents the available background filter processing engines.
843
+ */
844
+ var FilterEngine;
845
+ (function (FilterEngine) {
846
+ FilterEngine[FilterEngine["TF"] = 0] = "TF";
847
+ FilterEngine[FilterEngine["MEDIA_PIPE"] = 1] = "MEDIA_PIPE";
848
+ FilterEngine[FilterEngine["NONE"] = 2] = "NONE";
849
+ })(FilterEngine || (FilterEngine = {}));
850
+ /**
851
+ * Represents the possible reasons for background filter performance degradation.
852
+ */
853
+ exports.PerformanceDegradationReason = void 0;
854
+ (function (PerformanceDegradationReason) {
855
+ PerformanceDegradationReason["FRAME_DROP"] = "frame-drop";
856
+ PerformanceDegradationReason["CPU_THROTTLING"] = "cpu-throttling";
857
+ })(exports.PerformanceDegradationReason || (exports.PerformanceDegradationReason = {}));
830
858
  /**
831
859
  * The context for the background filters.
832
860
  */
@@ -841,6 +869,26 @@ const useBackgroundFilters = () => {
841
869
  }
842
870
  return context;
843
871
  };
872
+ /**
873
+ * Determines which filter engine is available.
874
+ * MEDIA_PIPE is the default unless legacy filters are requested or MediaPipe is unsupported.
875
+ *
876
+ * Returns NONE if neither is supported.
877
+ */
878
+ const determineEngine = async (useLegacyFilter, forceSafariSupport, forceMobileSupport) => {
879
+ const isTfPlatformSupported = await videoFiltersWeb.isPlatformSupported({
880
+ forceSafariSupport,
881
+ forceMobileSupport,
882
+ });
883
+ if (useLegacyFilter) {
884
+ return isTfPlatformSupported ? FilterEngine.TF : FilterEngine.NONE;
885
+ }
886
+ const isMediaPipeSupported = await videoFiltersWeb.isMediaPipePlatformSupported({
887
+ forceSafariSupport,
888
+ forceMobileSupport,
889
+ });
890
+ return isMediaPipeSupported ? FilterEngine.MEDIA_PIPE : FilterEngine.NONE;
891
+ };
844
892
  /**
845
893
  * A provider component that enables the use of background filters in your app.
846
894
  *
@@ -848,10 +896,88 @@ const useBackgroundFilters = () => {
848
896
  * in your project before using this component.
849
897
  */
850
898
  const BackgroundFiltersProvider = (props) => {
851
- const { children, backgroundImages = [], backgroundFilter: bgFilterFromProps = undefined, backgroundImage: bgImageFromProps = undefined, backgroundBlurLevel: bgBlurLevelFromProps = undefined, tfFilePath, modelFilePath, basePath, onError, forceSafariSupport, forceMobileSupport, } = props;
899
+ const { children, backgroundImages = [], backgroundFilter: bgFilterFromProps = undefined, backgroundImage: bgImageFromProps = undefined, backgroundBlurLevel: bgBlurLevelFromProps = undefined, tfFilePath, modelFilePath, useLegacyFilter, basePath, onError, performanceThresholds, forceSafariSupport, forceMobileSupport, } = props;
900
+ const call = videoReactBindings.useCall();
901
+ const { useCallStatsReport } = videoReactBindings.useCallStateHooks();
902
+ const callStatsReport = useCallStatsReport();
852
903
  const [backgroundFilter, setBackgroundFilter] = react.useState(bgFilterFromProps);
853
904
  const [backgroundImage, setBackgroundImage] = react.useState(bgImageFromProps);
854
905
  const [backgroundBlurLevel, setBackgroundBlurLevel] = react.useState(bgBlurLevelFromProps);
906
+ const [showLowFpsWarning, setShowLowFpsWarning] = react.useState(false);
907
+ const fpsWarningThresholdLower = performanceThresholds?.fpsWarningThresholdLower ??
908
+ FPS_WARNING_THRESHOLD_LOWER;
909
+ const fpsWarningThresholdUpper = performanceThresholds?.fpsWarningThresholdUpper ??
910
+ FPS_WARNING_THRESHOLD_UPPER;
911
+ const defaultFps = performanceThresholds?.defaultFps ?? DEFAULT_FPS;
912
+ const emaRef = react.useRef(defaultFps);
913
+ const outlierStreakRef = react.useRef(0);
914
+ const handleStats = react.useCallback((stats) => {
915
+ const fps = stats?.fps;
916
+ if (fps === undefined || fps === null) {
917
+ emaRef.current = defaultFps;
918
+ outlierStreakRef.current = 0;
919
+ setShowLowFpsWarning(false);
920
+ return;
921
+ }
922
+ const prevEma = emaRef.current;
923
+ const deviation = Math.abs(fps - prevEma) / prevEma;
924
+ const isOutlier = fps < prevEma && deviation > DEVIATION_LIMIT;
925
+ outlierStreakRef.current = isOutlier ? outlierStreakRef.current + 1 : 0;
926
+ if (isOutlier && outlierStreakRef.current < OUTLIER_PERSISTENCE)
927
+ return;
928
+ emaRef.current = ALPHA * fps + (1 - ALPHA) * prevEma;
929
+ setShowLowFpsWarning((prev) => {
930
+ if (prev && emaRef.current > fpsWarningThresholdUpper)
931
+ return false;
932
+ if (!prev && emaRef.current < fpsWarningThresholdLower)
933
+ return true;
934
+ return prev;
935
+ });
936
+ }, [fpsWarningThresholdLower, fpsWarningThresholdUpper, defaultFps]);
937
+ const performance = react.useMemo(() => {
938
+ if (!backgroundFilter) {
939
+ return { degraded: false };
940
+ }
941
+ const reasons = [];
942
+ if (showLowFpsWarning) {
943
+ reasons.push(exports.PerformanceDegradationReason.FRAME_DROP);
944
+ }
945
+ const qualityLimitationReasons = callStatsReport?.publisherStats?.qualityLimitationReasons;
946
+ if (showLowFpsWarning &&
947
+ qualityLimitationReasons &&
948
+ qualityLimitationReasons?.includes('cpu')) {
949
+ reasons.push(exports.PerformanceDegradationReason.CPU_THROTTLING);
950
+ }
951
+ return {
952
+ degraded: reasons.length > 0,
953
+ reason: reasons.length > 0 ? reasons : undefined,
954
+ };
955
+ }, [
956
+ showLowFpsWarning,
957
+ callStatsReport?.publisherStats?.qualityLimitationReasons,
958
+ backgroundFilter,
959
+ ]);
960
+ const prevDegradedRef = react.useRef(undefined);
961
+ react.useEffect(() => {
962
+ const currentDegraded = performance.degraded;
963
+ const prevDegraded = prevDegradedRef.current;
964
+ if (!!backgroundFilter &&
965
+ prevDegraded !== undefined &&
966
+ prevDegraded !== currentDegraded) {
967
+ call?.tracer.trace('backgroundFilters.performance', {
968
+ degraded: currentDegraded,
969
+ reason: performance?.reason,
970
+ fps: emaRef.current,
971
+ });
972
+ }
973
+ prevDegradedRef.current = currentDegraded;
974
+ }, [
975
+ performanceThresholds,
976
+ performance.degraded,
977
+ performance.reason,
978
+ backgroundFilter,
979
+ call?.tracer,
980
+ ]);
855
981
  const applyBackgroundImageFilter = react.useCallback((imageUrl) => {
856
982
  setBackgroundFilter('image');
857
983
  setBackgroundImage(imageUrl);
@@ -864,31 +990,47 @@ const BackgroundFiltersProvider = (props) => {
864
990
  setBackgroundFilter(undefined);
865
991
  setBackgroundImage(undefined);
866
992
  setBackgroundBlurLevel(undefined);
867
- }, []);
993
+ emaRef.current = defaultFps;
994
+ outlierStreakRef.current = 0;
995
+ setShowLowFpsWarning(false);
996
+ }, [defaultFps]);
997
+ const [engine, setEngine] = react.useState(FilterEngine.NONE);
868
998
  const [isSupported, setIsSupported] = react.useState(false);
869
999
  react.useEffect(() => {
870
- videoFiltersWeb.isPlatformSupported({
871
- forceSafariSupport,
872
- forceMobileSupport,
873
- }).then(setIsSupported);
874
- }, [forceMobileSupport, forceSafariSupport]);
1000
+ determineEngine(useLegacyFilter, forceSafariSupport, forceMobileSupport).then((determinedEngine) => {
1001
+ setEngine(determinedEngine);
1002
+ setIsSupported(determinedEngine !== FilterEngine.NONE);
1003
+ });
1004
+ }, [forceMobileSupport, forceSafariSupport, useLegacyFilter]);
875
1005
  const [tfLite, setTfLite] = react.useState();
876
1006
  react.useEffect(() => {
877
- // don't try to load TFLite if the platform is not supported
878
- if (!isSupported)
1007
+ if (engine !== FilterEngine.TF)
879
1008
  return;
880
1009
  videoFiltersWeb.loadTFLite({ basePath, modelFilePath, tfFilePath })
881
1010
  .then(setTfLite)
882
1011
  .catch((err) => console.error('Failed to load TFLite', err));
883
- }, [basePath, isSupported, modelFilePath, tfFilePath]);
1012
+ }, [basePath, engine, modelFilePath, tfFilePath]);
1013
+ const [mediaPipe, setMediaPipe] = react.useState();
1014
+ react.useEffect(() => {
1015
+ if (engine !== FilterEngine.MEDIA_PIPE)
1016
+ return;
1017
+ videoFiltersWeb.loadMediaPipe({
1018
+ basePath: basePath,
1019
+ modelPath: modelFilePath,
1020
+ })
1021
+ .then(setMediaPipe)
1022
+ .catch((err) => console.error('Failed to preload MediaPipe', err));
1023
+ }, [engine, modelFilePath, basePath]);
884
1024
  const handleError = react.useCallback((error) => {
885
1025
  console.warn('[filters] Filter encountered an error and will be disabled');
886
1026
  disableBackgroundFilter();
887
1027
  onError?.(error);
888
1028
  }, [disableBackgroundFilter, onError]);
1029
+ const isReady = useLegacyFilter ? !!tfLite : !!mediaPipe;
889
1030
  return (jsxRuntime.jsxs(BackgroundFiltersContext.Provider, { value: {
890
1031
  isSupported,
891
- isReady: !!tfLite,
1032
+ performance,
1033
+ isReady,
892
1034
  backgroundImage,
893
1035
  backgroundBlurLevel,
894
1036
  backgroundFilter,
@@ -900,26 +1042,30 @@ const BackgroundFiltersProvider = (props) => {
900
1042
  modelFilePath,
901
1043
  basePath,
902
1044
  onError: handleError,
903
- }, children: [children, tfLite && jsxRuntime.jsx(BackgroundFilters, { tfLite: tfLite })] }));
1045
+ }, children: [children, isReady && (jsxRuntime.jsx(BackgroundFilters, { tfLite: tfLite, engine: engine, onStats: handleStats }))] }));
904
1046
  };
905
1047
  const BackgroundFilters = (props) => {
906
1048
  const call = videoReactBindings.useCall();
907
- const { children, start } = useRenderer(props.tfLite, call);
908
- const { backgroundFilter, onError } = useBackgroundFilters();
1049
+ const { children, start } = useRenderer(props.tfLite, call, props.engine);
1050
+ const { onError, backgroundFilter } = useBackgroundFilters();
909
1051
  const handleErrorRef = react.useRef(undefined);
910
1052
  handleErrorRef.current = onError;
1053
+ const handleStatsRef = react.useRef(undefined);
1054
+ handleStatsRef.current = props.onStats;
911
1055
  react.useEffect(() => {
912
1056
  if (!call || !backgroundFilter)
913
1057
  return;
914
- const { unregister } = call.camera.registerFilter((ms) => start(ms, (error) => handleErrorRef.current?.(error)));
1058
+ const { unregister } = call.camera.registerFilter((ms) => {
1059
+ return start(ms, (error) => handleErrorRef.current?.(error), (stats) => handleStatsRef.current?.(stats));
1060
+ });
915
1061
  return () => {
916
1062
  unregister().catch((err) => console.warn(`Can't unregister filter`, err));
917
1063
  };
918
- }, [backgroundFilter, call, start]);
1064
+ }, [call, start, backgroundFilter]);
919
1065
  return children;
920
1066
  };
921
- const useRenderer = (tfLite, call) => {
922
- const { backgroundFilter, backgroundBlurLevel, backgroundImage } = useBackgroundFilters();
1067
+ const useRenderer = (tfLite, call, engine) => {
1068
+ const { backgroundFilter, backgroundBlurLevel, backgroundImage, modelFilePath, basePath, } = useBackgroundFilters();
923
1069
  const videoRef = react.useRef(null);
924
1070
  const canvasRef = react.useRef(null);
925
1071
  const bgImageRef = react.useRef(null);
@@ -927,8 +1073,9 @@ const useRenderer = (tfLite, call) => {
927
1073
  width: 1920,
928
1074
  height: 1080,
929
1075
  });
930
- const start = react.useCallback((ms, onError) => {
1076
+ const start = react.useCallback((ms, onError, onStats) => {
931
1077
  let outputStream;
1078
+ let processor;
932
1079
  let renderer;
933
1080
  const output = new Promise((resolve, reject) => {
934
1081
  if (!backgroundFilter) {
@@ -938,16 +1085,20 @@ const useRenderer = (tfLite, call) => {
938
1085
  const videoEl = videoRef.current;
939
1086
  const canvasEl = canvasRef.current;
940
1087
  const bgImageEl = bgImageRef.current;
941
- if (!videoEl || !canvasEl || (backgroundImage && !bgImageEl)) {
942
- // You should start renderer in effect or event handlers
943
- reject(new Error('Renderer started before elements are ready'));
1088
+ const [track] = ms.getVideoTracks();
1089
+ if (!track) {
1090
+ reject(new Error('No video tracks in input media stream'));
944
1091
  return;
945
1092
  }
946
- videoEl.srcObject = ms;
947
- videoEl.play().then(() => {
948
- const [track] = ms.getVideoTracks();
949
- if (!track) {
950
- reject(new Error('No video tracks in input media stream'));
1093
+ if (engine === FilterEngine.MEDIA_PIPE) {
1094
+ call?.tracer.trace('backgroundFilters.enable', {
1095
+ backgroundFilter,
1096
+ backgroundBlurLevel,
1097
+ backgroundImage,
1098
+ engine,
1099
+ });
1100
+ if (!videoEl) {
1101
+ reject(new Error('Renderer started before elements are ready'));
951
1102
  return;
952
1103
  }
953
1104
  const trackSettings = track.getSettings();
@@ -955,26 +1106,65 @@ const useRenderer = (tfLite, call) => {
955
1106
  width: trackSettings.width ?? 0,
956
1107
  height: trackSettings.height ?? 0,
957
1108
  }));
958
- call?.tracer.trace('backgroundFilters.enable', {
959
- backgroundFilter,
1109
+ processor = new videoFiltersWeb.VirtualBackground(track, {
1110
+ basePath: basePath,
1111
+ modelPath: modelFilePath,
960
1112
  backgroundBlurLevel,
961
1113
  backgroundImage,
962
- });
963
- renderer = videoFiltersWeb.createRenderer(tfLite, videoEl, canvasEl, {
964
1114
  backgroundFilter,
965
- backgroundBlurLevel,
966
- backgroundImage: bgImageEl ?? undefined,
967
- }, onError);
968
- outputStream = canvasEl.captureStream();
969
- resolve(outputStream);
970
- }, () => {
971
- reject(new Error('Could not play the source video stream'));
972
- });
1115
+ }, { onError, onStats });
1116
+ processor
1117
+ .start()
1118
+ .then((processedTrack) => {
1119
+ outputStream = new MediaStream([processedTrack]);
1120
+ resolve(outputStream);
1121
+ })
1122
+ .catch((error) => {
1123
+ reject(error);
1124
+ });
1125
+ return;
1126
+ }
1127
+ if (engine === FilterEngine.TF) {
1128
+ if (!videoEl || !canvasEl || (backgroundImage && !bgImageEl)) {
1129
+ reject(new Error('Renderer started before elements are ready'));
1130
+ return;
1131
+ }
1132
+ videoEl.srcObject = ms;
1133
+ videoEl.play().then(() => {
1134
+ const trackSettings = track.getSettings();
1135
+ reactDom.flushSync(() => setVideoSize({
1136
+ width: trackSettings.width ?? 0,
1137
+ height: trackSettings.height ?? 0,
1138
+ }));
1139
+ call?.tracer.trace('backgroundFilters.enable', {
1140
+ backgroundFilter,
1141
+ backgroundBlurLevel,
1142
+ backgroundImage,
1143
+ engine,
1144
+ });
1145
+ if (!tfLite) {
1146
+ reject(new Error('TensorFlow Lite not loaded'));
1147
+ return;
1148
+ }
1149
+ renderer = videoFiltersWeb.createRenderer(tfLite, videoEl, canvasEl, {
1150
+ backgroundFilter,
1151
+ backgroundBlurLevel,
1152
+ backgroundImage: bgImageEl ?? undefined,
1153
+ }, onError);
1154
+ outputStream = canvasEl.captureStream();
1155
+ resolve(outputStream);
1156
+ }, () => {
1157
+ reject(new Error('Could not play the source video stream'));
1158
+ });
1159
+ return;
1160
+ }
1161
+ reject(new Error('No supported engine available'));
973
1162
  });
974
1163
  return {
975
1164
  output,
976
1165
  stop: () => {
977
1166
  call?.tracer.trace('backgroundFilters.disable', null);
1167
+ processor?.stop();
978
1168
  renderer?.dispose();
979
1169
  if (videoRef.current)
980
1170
  videoRef.current.srcObject = null;
@@ -988,13 +1178,13 @@ const useRenderer = (tfLite, call) => {
988
1178
  backgroundImage,
989
1179
  call?.tracer,
990
1180
  tfLite,
1181
+ engine,
1182
+ modelFilePath,
1183
+ basePath,
991
1184
  ]);
992
1185
  const children = (jsxRuntime.jsxs("div", { className: "str-video__background-filters", children: [jsxRuntime.jsx("video", { className: clsx('str-video__background-filters__video', videoSize.height > videoSize.width &&
993
1186
  'str-video__background-filters__video--tall'), ref: videoRef, playsInline: true, muted: true, controls: false, ...videoSize }), backgroundImage && (jsxRuntime.jsx("img", { className: "str-video__background-filters__background-image", alt: "Background", ref: bgImageRef, crossOrigin: "anonymous", src: backgroundImage, ...videoSize })), jsxRuntime.jsx("canvas", { className: "str-video__background-filters__target-canvas", ...videoSize, ref: canvasRef })] }));
994
- return {
995
- start,
996
- children,
997
- };
1187
+ return { start, children };
998
1188
  };
999
1189
 
1000
1190
  const IconButton = react.forwardRef(function IconButton(props, ref) {
@@ -1047,7 +1237,7 @@ const AcceptCallButton = ({ disabled, onAccept, onClick, }) => {
1047
1237
  };
1048
1238
 
1049
1239
  const Notification = (props) => {
1050
- const { isVisible, message, children, visibilityTimeout, resetIsVisible, placement = 'top', iconClassName = 'str-video__notification__icon', close, } = props;
1240
+ const { isVisible, message, children, visibilityTimeout, resetIsVisible, placement = 'top', className, iconClassName = 'str-video__notification__icon', close, } = props;
1051
1241
  const { refs, x, y, strategy } = useFloatingUIPreset({
1052
1242
  placement,
1053
1243
  strategy: 'absolute',
@@ -1060,7 +1250,7 @@ const Notification = (props) => {
1060
1250
  }, visibilityTimeout);
1061
1251
  return () => clearTimeout(timeout);
1062
1252
  }, [isVisible, resetIsVisible, visibilityTimeout]);
1063
- return (jsxRuntime.jsxs("div", { ref: refs.setReference, children: [isVisible && (jsxRuntime.jsxs("div", { className: "str-video__notification", ref: refs.setFloating, style: {
1253
+ return (jsxRuntime.jsxs("div", { ref: refs.setReference, children: [isVisible && (jsxRuntime.jsxs("div", { className: clsx('str-video__notification', className), ref: refs.setFloating, style: {
1064
1254
  position: strategy,
1065
1255
  top: y ?? 0,
1066
1256
  left: x ?? 0,
@@ -1331,6 +1521,21 @@ const ScreenShareButton = (props) => {
1331
1521
  : 'screen-share-start-button', disabled: disableScreenShareButton, onClick: handleClick, children: jsxRuntime.jsx(Icon, { icon: isSomeoneScreenSharing ? 'screen-share-on' : 'screen-share-off' }) }) }) }) }));
1332
1522
  };
1333
1523
 
1524
+ const AudioVolumeIndicator = () => {
1525
+ const { useMicrophoneState } = videoReactBindings.useCallStateHooks();
1526
+ const { isEnabled, mediaStream } = useMicrophoneState();
1527
+ const [audioLevel, setAudioLevel] = react.useState(0);
1528
+ react.useEffect(() => {
1529
+ if (!isEnabled || !mediaStream)
1530
+ return;
1531
+ const disposeSoundDetector = videoClient.createSoundDetector(mediaStream, ({ audioLevel: al }) => setAudioLevel(al), { detectionFrequencyInMs: 80, destroyStreamOnStop: false });
1532
+ return () => {
1533
+ disposeSoundDetector().catch(console.error);
1534
+ };
1535
+ }, [isEnabled, mediaStream]);
1536
+ return (jsxRuntime.jsxs("div", { className: "str-video__audio-volume-indicator", children: [jsxRuntime.jsx(Icon, { icon: isEnabled ? 'mic' : 'mic-off' }), jsxRuntime.jsx("div", { className: "str-video__audio-volume-indicator__bar", children: jsxRuntime.jsx("div", { className: "str-video__audio-volume-indicator__bar-value", style: { transform: `scaleX(${audioLevel / 100})` } }) })] }));
1537
+ };
1538
+
1334
1539
  const SelectContext = react.createContext({});
1335
1540
  const Select = (props) => {
1336
1541
  const { children, icon, defaultSelectedLabel, defaultSelectedIndex, handleSelect: handleSelectProp, } = props;
@@ -1441,19 +1646,52 @@ const DeviceSelector = (props) => {
1441
1646
  return jsxRuntime.jsx(DeviceSelectorDropdown, { ...rest, icon: icon });
1442
1647
  };
1443
1648
 
1444
- const AudioVolumeIndicator = () => {
1445
- const { useMicrophoneState } = videoReactBindings.useCallStateHooks();
1446
- const { isEnabled, mediaStream } = useMicrophoneState();
1447
- const [audioLevel, setAudioLevel] = react.useState(0);
1649
+ /**
1650
+ * SpeakerTest component that plays a test audio through the selected speaker.
1651
+ * This allows users to verify their audio output device is working correctly.
1652
+ */
1653
+ const SpeakerTest = (props) => {
1654
+ const { useSpeakerState } = videoReactBindings.useCallStateHooks();
1655
+ const { selectedDevice } = useSpeakerState();
1656
+ const audioElementRef = react.useRef(null);
1657
+ const [isPlaying, setIsPlaying] = react.useState(false);
1658
+ const { t } = videoReactBindings.useI18n();
1659
+ const { audioUrl = `https://unpkg.com/${"@stream-io/video-react-sdk"}@${"1.27.0"}/assets/piano.mp3`, } = props;
1660
+ // Update audio output device when selection changes
1448
1661
  react.useEffect(() => {
1449
- if (!isEnabled || !mediaStream)
1662
+ const audio = audioElementRef.current;
1663
+ if (!audio || !selectedDevice)
1450
1664
  return;
1451
- const disposeSoundDetector = videoClient.createSoundDetector(mediaStream, ({ audioLevel: al }) => setAudioLevel(al), { detectionFrequencyInMs: 80, destroyStreamOnStop: false });
1452
- return () => {
1453
- disposeSoundDetector().catch(console.error);
1454
- };
1455
- }, [isEnabled, mediaStream]);
1456
- return (jsxRuntime.jsxs("div", { className: "str-video__audio-volume-indicator", children: [jsxRuntime.jsx(Icon, { icon: isEnabled ? 'mic' : 'mic-off' }), jsxRuntime.jsx("div", { className: "str-video__audio-volume-indicator__bar", children: jsxRuntime.jsx("div", { className: "str-video__audio-volume-indicator__bar-value", style: { transform: `scaleX(${audioLevel / 100})` } }) })] }));
1665
+ // Set the sinkId to route audio to the selected speaker
1666
+ if ('setSinkId' in audio) {
1667
+ audio.setSinkId(selectedDevice).catch((err) => {
1668
+ console.error('Failed to set audio output device:', err);
1669
+ });
1670
+ }
1671
+ }, [selectedDevice]);
1672
+ const handleStartTest = react.useCallback(async () => {
1673
+ const audio = audioElementRef.current;
1674
+ if (!audio)
1675
+ return;
1676
+ audio.src = audioUrl;
1677
+ try {
1678
+ if (isPlaying) {
1679
+ audio.pause();
1680
+ audio.currentTime = 0;
1681
+ setIsPlaying(false);
1682
+ }
1683
+ else {
1684
+ await audio.play();
1685
+ setIsPlaying(true);
1686
+ }
1687
+ }
1688
+ catch (err) {
1689
+ console.error('Failed to play test audio:', err);
1690
+ setIsPlaying(false);
1691
+ }
1692
+ }, [isPlaying, audioUrl]);
1693
+ const handleAudioEnded = react.useCallback(() => setIsPlaying(false), []);
1694
+ return (jsxRuntime.jsxs("div", { className: "str-video__speaker-test", children: [jsxRuntime.jsx("audio", { ref: audioElementRef, onEnded: handleAudioEnded, onPause: handleAudioEnded }), jsxRuntime.jsx(CompositeButton, { className: "str-video__speaker-test__button", onClick: handleStartTest, type: "button", children: jsxRuntime.jsxs("div", { className: "str-video__speaker-test__button-content", children: [jsxRuntime.jsx(Icon, { icon: "speaker" }), isPlaying ? t('Stop test') : t('Test speaker')] }) })] }));
1457
1695
  };
1458
1696
 
1459
1697
  const DeviceSelectorAudioInput = ({ title, visualType, volumeIndicatorVisible = true, }) => {
@@ -1463,14 +1701,14 @@ const DeviceSelectorAudioInput = ({ title, visualType, volumeIndicatorVisible =
1463
1701
  await microphone.select(deviceId);
1464
1702
  }, title: title, visualType: visualType, icon: "mic", children: volumeIndicatorVisible && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("hr", { className: "str-video__device-settings__separator" }), jsxRuntime.jsx(AudioVolumeIndicator, {})] })) }));
1465
1703
  };
1466
- const DeviceSelectorAudioOutput = ({ title, visualType, }) => {
1704
+ const DeviceSelectorAudioOutput = ({ title, visualType, speakerTestVisible = true, speakerTestAudioUrl, }) => {
1467
1705
  const { useSpeakerState } = videoReactBindings.useCallStateHooks();
1468
1706
  const { speaker, selectedDevice, devices, isDeviceSelectionSupported } = useSpeakerState();
1469
1707
  if (!isDeviceSelectionSupported)
1470
1708
  return null;
1471
1709
  return (jsxRuntime.jsx(DeviceSelector, { devices: devices, type: "audiooutput", selectedDeviceId: selectedDevice, onChange: (deviceId) => {
1472
1710
  speaker.select(deviceId);
1473
- }, title: title, visualType: visualType, icon: "speaker" }));
1711
+ }, title: title, visualType: visualType, icon: "speaker", children: speakerTestVisible && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("hr", { className: "str-video__device-settings__separator" }), jsxRuntime.jsx(SpeakerTest, { audioUrl: speakerTestAudioUrl })] })) }));
1474
1712
  };
1475
1713
 
1476
1714
  const DeviceSelectorVideo = ({ title, visualType, }) => {
@@ -2385,6 +2623,7 @@ var en = {
2385
2623
  Speakers: Speakers,
2386
2624
  Video: Video,
2387
2625
  "You are muted. Unmute to speak.": "You are muted. Unmute to speak.",
2626
+ "Background filters performance is degraded. Consider disabling filters for better performance.": "Background filters performance is degraded. Consider disabling filters for better performance.",
2388
2627
  Live: Live,
2389
2628
  "Livestream starts soon": "Livestream starts soon",
2390
2629
  "Livestream starts at {{ startsAt }}": "Livestream starts at {{ startsAt, datetime }}",
@@ -2657,13 +2896,14 @@ const LivestreamLayout = (props) => {
2657
2896
  ? participants.find(videoClient.hasScreenShare)
2658
2897
  : undefined;
2659
2898
  usePaginatedLayoutSortPreset(call);
2660
- const overlay = (jsxRuntime.jsx(ParticipantOverlay, { showParticipantCount: props.showParticipantCount, showDuration: props.showDuration, showLiveBadge: props.showLiveBadge, showSpeakerName: props.showSpeakerName }));
2661
- const { floatingParticipantProps, muted } = props;
2662
- const floatingParticipantOverlay = hasOngoingScreenShare && (jsxRuntime.jsx(ParticipantOverlay
2663
- // these elements aren't needed for the video feed
2664
- , {
2899
+ const { floatingParticipantProps, muted, ParticipantViewUI } = props;
2900
+ const overlay = ParticipantViewUI ?? (jsxRuntime.jsx(ParticipantOverlay, { showParticipantCount: props.showParticipantCount, showDuration: props.showDuration, showLiveBadge: props.showLiveBadge, showSpeakerName: props.showSpeakerName, enableFullScreen: props.enableFullScreen }));
2901
+ const floatingParticipantOverlay = hasOngoingScreenShare &&
2902
+ (ParticipantViewUI ?? (jsxRuntime.jsx(ParticipantOverlay
2665
2903
  // these elements aren't needed for the video feed
2666
- showParticipantCount: floatingParticipantProps?.showParticipantCount ?? false, showDuration: floatingParticipantProps?.showDuration ?? false, showLiveBadge: floatingParticipantProps?.showLiveBadge ?? false, showSpeakerName: floatingParticipantProps?.showSpeakerName ?? true }));
2904
+ , {
2905
+ // these elements aren't needed for the video feed
2906
+ showParticipantCount: floatingParticipantProps?.showParticipantCount ?? false, showDuration: floatingParticipantProps?.showDuration ?? false, showLiveBadge: floatingParticipantProps?.showLiveBadge ?? false, showSpeakerName: floatingParticipantProps?.showSpeakerName ?? true, enableFullScreen: floatingParticipantProps?.enableFullScreen ?? true })));
2667
2907
  return (jsxRuntime.jsxs("div", { className: "str-video__livestream-layout__wrapper", children: [!muted && jsxRuntime.jsx(ParticipantsAudio, { participants: remoteParticipants }), hasOngoingScreenShare && presenter && (jsxRuntime.jsx(ParticipantView, { className: "str-video__livestream-layout__screen-share", participant: presenter, ParticipantViewUI: overlay, trackType: "screenShareTrack", muteAudio // audio is rendered by ParticipantsAudio
2668
2908
  : true })), currentSpeaker && (jsxRuntime.jsx(ParticipantView, { className: clsx(hasOngoingScreenShare &&
2669
2909
  clsx('str-video__livestream-layout__floating-participant', `str-video__livestream-layout__floating-participant--${floatingParticipantProps?.position ?? 'top-right'}`)), participant: currentSpeaker, ParticipantViewUI: floatingParticipantOverlay || overlay, mirror: props.mirrorLocalParticipantVideo !== false ? undefined : false, muteAudio // audio is rendered by ParticipantsAudio
@@ -2685,13 +2925,18 @@ const BackstageLayout = (props) => {
2685
2925
  BackstageLayout.displayName = 'BackstageLayout';
2686
2926
  const ParticipantOverlay = (props) => {
2687
2927
  const { enableFullScreen = true, showParticipantCount = true, showDuration = true, showLiveBadge = true, showSpeakerName = false, } = props;
2928
+ const overlayBarVisible = enableFullScreen ||
2929
+ showParticipantCount ||
2930
+ showDuration ||
2931
+ showLiveBadge ||
2932
+ showSpeakerName;
2688
2933
  const { participant } = useParticipantViewContext();
2689
2934
  const { useParticipantCount } = videoReactBindings.useCallStateHooks();
2690
2935
  const participantCount = useParticipantCount();
2691
2936
  const duration = useUpdateCallDuration();
2692
2937
  const toggleFullScreen = useToggleFullScreen();
2693
2938
  const { t } = videoReactBindings.useI18n();
2694
- return (jsxRuntime.jsx("div", { className: "str-video__livestream-layout__overlay", children: jsxRuntime.jsxs("div", { className: "str-video__livestream-layout__overlay__bar", children: [showLiveBadge && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__live-badge", children: t('Live') })), showParticipantCount && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__viewers-count", children: participantCount })), showSpeakerName && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__speaker-name", title: participant.name || participant.userId || '', children: participant.name || participant.userId || '' })), showDuration && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__duration", children: formatDuration(duration) })), enableFullScreen && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__go-fullscreen", onClick: toggleFullScreen }))] }) }));
2939
+ return (jsxRuntime.jsx("div", { className: "str-video__livestream-layout__overlay", children: overlayBarVisible && (jsxRuntime.jsxs("div", { className: "str-video__livestream-layout__overlay__bar", children: [showLiveBadge && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__live-badge", children: t('Live') })), showParticipantCount && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__viewers-count", children: participantCount })), showSpeakerName && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__speaker-name", title: participant.name || participant.userId || '', children: participant.name || participant.userId || '' })), showDuration && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__duration", children: formatDuration(duration) })), enableFullScreen && (jsxRuntime.jsx("span", { className: "str-video__livestream-layout__go-fullscreen", onClick: toggleFullScreen }))] })) }));
2695
2940
  };
2696
2941
  const useUpdateCallDuration = () => {
2697
2942
  const { useIsCallLive, useCallSession } = videoReactBindings.useCallStateHooks();
@@ -3020,7 +3265,7 @@ const checkCanJoinEarly = (startsAt, joinAheadTimeSeconds) => {
3020
3265
  return Date.now() >= +startsAt - (joinAheadTimeSeconds ?? 0) * 1000;
3021
3266
  };
3022
3267
 
3023
- const [major, minor, patch] = ("1.26.0").split('.');
3268
+ const [major, minor, patch] = ("1.27.0").split('.');
3024
3269
  videoClient.setSdkInfo({
3025
3270
  type: videoClient.SfuModels.SdkType.REACT,
3026
3271
  major,
@@ -3030,6 +3275,7 @@ videoClient.setSdkInfo({
3030
3275
 
3031
3276
  exports.AcceptCallButton = AcceptCallButton;
3032
3277
  exports.Audio = Audio;
3278
+ exports.AudioVolumeIndicator = AudioVolumeIndicator;
3033
3279
  exports.Avatar = Avatar;
3034
3280
  exports.AvatarFallback = AvatarFallback;
3035
3281
  exports.BackgroundFiltersProvider = BackgroundFiltersProvider;
@@ -3092,6 +3338,7 @@ exports.ScreenShareButton = ScreenShareButton;
3092
3338
  exports.SearchInput = SearchInput;
3093
3339
  exports.SearchResults = SearchResults;
3094
3340
  exports.SpeakerLayout = SpeakerLayout;
3341
+ exports.SpeakerTest = SpeakerTest;
3095
3342
  exports.SpeakingWhileMutedNotification = SpeakingWhileMutedNotification;
3096
3343
  exports.SpeechIndicator = SpeechIndicator;
3097
3344
  exports.StatCard = StatCard;