Skip to content

Commit cdc0c4f

Browse files
oliverlazgreenfrvr
andauthored
feat(LivestreamLayout): Enrich with mute option and humanized participant count (#2027)
Extends the `LivestreamLayout` with additional options for showing: - humanized participant count (`1000 -> 1k; 1500 -> 1.5k; 123333 -> 123k...`) - add an option to mute the speaker <img width="442" height="888" alt="Screenshot 2025-12-01 at 14 19 37" src="https://github.com/user-attachments/assets/ca702d94-90a3-4538-8c3b-d5ad9e82d2c2" /> 🎫 Ticket: https://linear.app/stream/issue/REACT-677 📑 Docs: GetStream/docs-content#816 --------- Co-authored-by: Artsiom Grintsevich <[email protected]>
1 parent 8f38784 commit cdc0c4f

File tree

11 files changed

+167
-41
lines changed

11 files changed

+167
-41
lines changed

packages/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './src/devices';
1717
export * from './src/store';
1818
export * from './src/sorting';
1919
export * from './src/helpers/client-details';
20+
export * from './src/helpers/humanize';
2021
export * from './src/helpers/DynascaleManager';
2122
export * from './src/helpers/ViewportTracker';
2223
export * from './src/helpers/sound-detector';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { humanize } from '../humanize';
4+
5+
describe('humanize', () => {
6+
it('returns the same string for numbers below 1000', () => {
7+
expect(humanize(0)).toBe('0');
8+
expect(humanize(1)).toBe('1');
9+
expect(humanize(12)).toBe('12');
10+
expect(humanize(999)).toBe('999');
11+
});
12+
13+
it('formats thousands with k suffix', () => {
14+
expect(humanize(1000)).toBe('1k');
15+
expect(humanize(1500)).toBe('1.5k');
16+
expect(humanize(12_300)).toBe('12.3k');
17+
// >= 100 of the unit → no decimals
18+
expect(humanize(123_456)).toBe('123k');
19+
});
20+
21+
it('formats millions with M suffix', () => {
22+
expect(humanize(1_000_000)).toBe('1M'); // trailing .0 removed
23+
expect(humanize(1_500_000)).toBe('1.5M');
24+
expect(humanize(12_000_000)).toBe('12M');
25+
});
26+
27+
it('formats billions with B suffix', () => {
28+
expect(humanize(1_000_000_000)).toBe('1B');
29+
expect(humanize(1_250_000_000)).toBe('1.3B');
30+
expect(humanize(12_345_678_901)).toBe('12.3B');
31+
});
32+
33+
it('rounds within the same unit and removes trailing .0', () => {
34+
// Rounds up within the same unit (k), does not carry over to the next unit
35+
expect(humanize(999_500)).toBe('1000k');
36+
// Rounds to a whole number for millions when first decimal rounds to .0
37+
expect(humanize(99_950_000)).toBe('100M');
38+
// No trailing .0
39+
expect(humanize(100_000)).toBe('100k');
40+
});
41+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Formats large numbers into a compact, human-friendly form: 1k, 1.5k, 2M, etc.
3+
*/
4+
export const humanize = (n: number): string => {
5+
if (n < 1000) return String(n);
6+
const units = [
7+
{ value: 1_000_000_000, suffix: 'B' },
8+
{ value: 1_000_000, suffix: 'M' },
9+
{ value: 1_000, suffix: 'k' },
10+
];
11+
for (const { value, suffix } of units) {
12+
if (n >= value) {
13+
const num = n / value;
14+
const precision = num < 100 ? 1 : 0; // show one decimal only for small leading numbers
15+
const formatted = num.toFixed(precision).replace(/\.0$/g, '');
16+
return `${formatted}${suffix}`;
17+
}
18+
}
19+
return String(n);
20+
};

packages/react-bindings/src/hooks/callStateHooks.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,17 +429,19 @@ export const useMicrophoneState = ({
429429
export const useSpeakerState = () => {
430430
if (isReactNative()) {
431431
throw new Error(
432-
'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
432+
'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/react-native/guides/camera-and-microphone/#speaker-management for more details',
433433
);
434434
}
435435
const call = useCall();
436436
const { speaker } = call as Call;
437437

438438
const { getDevices } = useLazyDeviceList(speaker);
439439
const selectedDevice = useObservableValue(speaker.state.selectedDevice$);
440+
const volume = useObservableValue(speaker.state.volume$);
440441

441442
return {
442443
speaker,
444+
volume,
443445
get devices() {
444446
return getDevices();
445447
},

packages/react-native-sdk/src/components/Livestream/LivestreamControls/ViewerLivestreamControls.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export type ViewerLivestreamControlsProps = ViewerLeaveStreamButtonProps & {
4444
* Handler to be called when the layout of the component changes.
4545
*/
4646
onLayout?: ViewProps['onLayout'];
47+
48+
/**
49+
* Whether to humanize the participant count.
50+
* @default true
51+
* @example 1000 -> 1k; 1500 -> 1.5k
52+
*/
53+
humanizeParticipantCount?: boolean;
4754
};
4855

4956
/**
@@ -53,6 +60,7 @@ export const ViewerLivestreamControls = ({
5360
ViewerLeaveStreamButton = DefaultViewerLeaveStreamButton,
5461
onLeaveStreamHandler,
5562
onLayout,
63+
humanizeParticipantCount,
5664
}: ViewerLivestreamControlsProps) => {
5765
const styles = useStyles();
5866
const {
@@ -187,7 +195,9 @@ export const ViewerLivestreamControls = ({
187195
<View style={[styles.leftElement]}>
188196
<View style={[styles.liveInfo]}>
189197
<LiveIndicator />
190-
<FollowerCount />
198+
<FollowerCount
199+
humanizeParticipantCount={humanizeParticipantCount}
200+
/>
191201
</View>
192202
</View>
193203
</View>

packages/react-native-sdk/src/components/Livestream/LivestreamTopView/FollowerCount.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import React, { useMemo } from 'react';
22
import { StyleSheet, Text, View } from 'react-native';
33
import { useTheme } from '../../../contexts';
4+
import { humanize } from '@stream-io/video-client';
45
import { useCallStateHooks } from '@stream-io/video-react-bindings';
56
import { Eye } from '../../../icons';
67

78
/**
89
* Props for the FollowerCount component.
910
*/
10-
export type FollowerCountProps = {};
11+
export type FollowerCountProps = {
12+
/**
13+
* Humanize the participant count. @default true
14+
* @example 1000 -> 1k
15+
* @example 1450 -> 1.45k
16+
* @example 1000000 -> 1m
17+
*/
18+
humanizeParticipantCount?: boolean;
19+
};
1120

1221
/**
1322
* The FollowerCount component that displays the number of participants while in the call.
1423
*/
15-
export const FollowerCount = ({}: FollowerCountProps) => {
24+
export const FollowerCount = ({
25+
humanizeParticipantCount = true,
26+
}: FollowerCountProps) => {
1627
const styles = useStyles();
1728
const {
1829
theme: { followerCount },
@@ -27,7 +38,9 @@ export const FollowerCount = ({}: FollowerCountProps) => {
2738
<Eye />
2839
</View>
2940
<Text style={[styles.label, followerCount.label]}>
30-
{totalParticipants}
41+
{humanizeParticipantCount
42+
? humanize(totalParticipants)
43+
: totalParticipants}
3144
</Text>
3245
</View>
3346
);

packages/react-native-sdk/src/components/Livestream/ViewerLivestream/ViewerLivestream.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,21 +127,15 @@ export const ViewerLivestream = ({
127127
};
128128

129129
useEffect(() => {
130-
const handleJoinCall = async () => {
131-
try {
132-
await call?.join();
133-
} catch (error) {
134-
console.error('Failed to join call', error);
135-
}
136-
};
137-
138130
const canJoinAsap = canJoinLive || canJoinEarly || canJoinBackstage;
139131
const join = joinBehavior ?? 'asap';
140132
const canJoin =
141133
(join === 'asap' && canJoinAsap) || (join === 'live' && canJoinLive);
142134

143135
if (call && callingState === CallingState.IDLE && canJoin && !hasLeft) {
144-
handleJoinCall();
136+
call.join().catch((error) => {
137+
console.error('Failed to join call', error);
138+
});
145139
}
146140
}, [
147141
canJoinLive,

packages/react-sdk/src/core/components/CallLayout/LivestreamLayout.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useCallStateHooks,
1212
useI18n,
1313
} from '@stream-io/video-react-bindings';
14-
import { hasScreenShare } from '@stream-io/video-client';
14+
import { hasScreenShare, humanize } from '@stream-io/video-client';
1515
import { ParticipantView, useParticipantViewContext } from '../ParticipantView';
1616
import { ParticipantsAudio } from '../Audio';
1717
import {
@@ -33,6 +33,16 @@ export type LivestreamLayoutProps = {
3333
*/
3434
showParticipantCount?: boolean;
3535

36+
/**
37+
* Whether to humanize the participant count. Defaults to `true`.
38+
* @example
39+
* 1000 participants -> 1k
40+
* 1500 participants -> 1.5k
41+
* 10_000 participants -> 10k
42+
* 100_000 participants -> 100k
43+
*/
44+
humanizeParticipantCount?: boolean;
45+
3646
/**
3747
* Whether to enable fullscreen mode. Defaults to `true`.
3848
*/
@@ -53,6 +63,11 @@ export type LivestreamLayoutProps = {
5363
*/
5464
showSpeakerName?: boolean;
5565

66+
/**
67+
* Whether to show the mute button. Defaults to `true`.
68+
*/
69+
showMuteButton?: boolean;
70+
5671
/**
5772
* When set to `false` disables mirroring of the local participant's video.
5873
* @default true
@@ -94,6 +109,7 @@ export const LivestreamLayout = (props: LivestreamLayoutProps) => {
94109
showParticipantCount={props.showParticipantCount}
95110
showDuration={props.showDuration}
96111
showLiveBadge={props.showLiveBadge}
112+
showMuteButton={props.showMuteButton}
97113
showSpeakerName={props.showSpeakerName}
98114
enableFullScreen={props.enableFullScreen}
99115
/>
@@ -160,10 +176,21 @@ export type BackstageLayoutProps = {
160176
* the livestream went live. Defaults to `true`.
161177
*/
162178
showEarlyParticipantCount?: boolean;
179+
180+
/**
181+
* Show the participant count in a humanized format. Defaults to `true`.
182+
* @example
183+
* 1000 participants -> 1k
184+
* 1500 participants -> 1.5k
185+
* 10_000 participants -> 10k
186+
* 10_0000 participants -> 100k
187+
*/
188+
humanizeParticipantCount?: boolean;
163189
};
164190

165191
export const BackstageLayout = (props: BackstageLayoutProps) => {
166-
const { showEarlyParticipantCount = true } = props;
192+
const { showEarlyParticipantCount = true, humanizeParticipantCount = true } =
193+
props;
167194
const { useParticipantCount, useCallStartsAt } = useCallStateHooks();
168195
const participantCount = useParticipantCount();
169196
const startsAt = useCallStartsAt();
@@ -182,7 +209,9 @@ export const BackstageLayout = (props: BackstageLayoutProps) => {
182209
{showEarlyParticipantCount && (
183210
<span className="str-video__livestream-layout__early-viewers-count">
184211
{t('{{ count }} participants joined early', {
185-
count: participantCount,
212+
count: humanizeParticipantCount
213+
? humanize(participantCount)
214+
: participantCount,
186215
})}
187216
</span>
188217
)}
@@ -196,28 +225,35 @@ BackstageLayout.displayName = 'BackstageLayout';
196225
const ParticipantOverlay = (props: {
197226
enableFullScreen?: boolean;
198227
showParticipantCount?: boolean;
228+
humanizeParticipantCount?: boolean;
199229
showDuration?: boolean;
200230
showLiveBadge?: boolean;
201231
showSpeakerName?: boolean;
232+
showMuteButton?: boolean;
202233
}) => {
203234
const {
204235
enableFullScreen = true,
205236
showParticipantCount = true,
237+
humanizeParticipantCount = true,
206238
showDuration = true,
207239
showLiveBadge = true,
240+
showMuteButton = true,
208241
showSpeakerName = false,
209242
} = props;
210243
const overlayBarVisible =
211244
enableFullScreen ||
212245
showParticipantCount ||
213246
showDuration ||
214247
showLiveBadge ||
248+
showMuteButton ||
215249
showSpeakerName;
216250
const { participant } = useParticipantViewContext();
217-
const { useParticipantCount } = useCallStateHooks();
251+
const { useParticipantCount, useSpeakerState } = useCallStateHooks();
218252
const participantCount = useParticipantCount();
219253
const duration = useUpdateCallDuration();
220254
const toggleFullScreen = useToggleFullScreen();
255+
const { speaker, volume } = useSpeakerState();
256+
const isSpeakerMuted = volume === 0;
221257
const { t } = useI18n();
222258
return (
223259
<div className="str-video__livestream-layout__overlay">
@@ -230,7 +266,9 @@ const ParticipantOverlay = (props: {
230266
)}
231267
{showParticipantCount && (
232268
<span className="str-video__livestream-layout__viewers-count">
233-
{participantCount}
269+
{humanizeParticipantCount
270+
? humanize(participantCount)
271+
: participantCount}
234272
</span>
235273
)}
236274
{showSpeakerName && (
@@ -246,6 +284,16 @@ const ParticipantOverlay = (props: {
246284
{formatDuration(duration)}
247285
</span>
248286
)}
287+
{showMuteButton && (
288+
<span
289+
className={clsx(
290+
'str-video__livestream-layout__mute-button',
291+
isSpeakerMuted &&
292+
'str-video__livestream-layout__mute-button--muted',
293+
)}
294+
onClick={() => speaker.setVolume(isSpeakerMuted ? 1 : 0)}
295+
/>
296+
)}
249297
{enableFullScreen && (
250298
<span
251299
className="str-video__livestream-layout__go-fullscreen"

packages/styling/src/CallLayout/LivestreamLayout-layout.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@
100100
text-align: center;
101101
}
102102

103+
.str-video__livestream-layout__mute-button {
104+
background: var(--str-video__icon--speaker) center no-repeat;
105+
border-radius: var(--str-video__border-radius-xxs);
106+
cursor: pointer;
107+
width: 32px;
108+
height: 32px;
109+
110+
&.str-video__livestream-layout__mute-button--muted {
111+
background: var(--str-video__icon--speaker-off) center no-repeat;
112+
}
113+
114+
&:hover {
115+
background-color: var(--str-video__overlay-color);
116+
}
117+
}
118+
103119
.str-video__livestream-layout__go-fullscreen {
104120
background: var(--str-video__icon--fullscreen) center no-repeat;
105121
border-radius: var(--str-video__border-radius-xxs);

0 commit comments

Comments
 (0)