@streamplace/components 0.6.37 → 0.7.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/components/chat/chat-box.js +109 -0
- package/dist/components/chat/chat-message.js +76 -0
- package/dist/components/chat/chat.js +56 -0
- package/dist/components/chat/mention-suggestions.js +39 -0
- package/dist/components/chat/mod-view.js +33 -0
- package/dist/components/mobile-player/fullscreen.js +69 -0
- package/dist/components/mobile-player/fullscreen.native.js +151 -0
- package/dist/components/mobile-player/player.js +103 -0
- package/dist/components/mobile-player/props.js +1 -0
- package/dist/components/mobile-player/shared.js +51 -0
- package/dist/components/mobile-player/ui/countdown.js +79 -0
- package/dist/components/mobile-player/ui/index.js +5 -0
- package/dist/components/mobile-player/ui/input.js +38 -0
- package/dist/components/mobile-player/ui/metrics.js +40 -0
- package/dist/components/mobile-player/ui/streamer-context-menu.js +4 -0
- package/dist/components/mobile-player/ui/viewer-context-menu.js +20 -0
- package/dist/components/mobile-player/use-webrtc.js +232 -0
- package/dist/components/mobile-player/video.js +375 -0
- package/dist/components/mobile-player/video.native.js +238 -0
- package/dist/components/mobile-player/webrtc-diagnostics.js +106 -0
- package/dist/components/mobile-player/webrtc-primitives.js +25 -0
- package/dist/components/mobile-player/webrtc-primitives.native.js +1 -0
- package/dist/components/ui/button.js +220 -0
- package/dist/components/ui/dialog.js +203 -0
- package/dist/components/ui/dropdown.js +148 -0
- package/dist/components/ui/icons.js +22 -0
- package/dist/components/ui/index.js +22 -0
- package/dist/components/ui/input.js +202 -0
- package/dist/components/ui/loader.js +7 -0
- package/dist/components/ui/primitives/button.js +121 -0
- package/dist/components/ui/primitives/input.js +202 -0
- package/dist/components/ui/primitives/modal.js +203 -0
- package/dist/components/ui/primitives/text.js +286 -0
- package/dist/components/ui/resizeable.js +101 -0
- package/dist/components/ui/text.js +175 -0
- package/dist/components/ui/textarea.js +17 -0
- package/dist/components/ui/toast.js +129 -0
- package/dist/components/ui/view.js +250 -0
- package/dist/hooks/index.js +9 -0
- package/dist/hooks/useAvatars.js +32 -0
- package/dist/hooks/useCameraToggle.js +9 -0
- package/dist/hooks/useKeyboard.js +33 -0
- package/dist/hooks/useKeyboardSlide.js +11 -0
- package/dist/hooks/useLivestreamInfo.js +62 -0
- package/dist/hooks/useOuterAndInnerDimensions.js +27 -0
- package/dist/hooks/usePlayerDimensions.js +19 -0
- package/dist/hooks/useSegmentTiming.js +62 -0
- package/dist/index.js +10 -0
- package/dist/lib/facet.js +88 -0
- package/dist/lib/theme/atoms.js +620 -0
- package/dist/lib/theme/atoms.types.js +5 -0
- package/dist/lib/theme/index.js +9 -0
- package/dist/lib/theme/theme.js +248 -0
- package/dist/lib/theme/tokens.js +383 -0
- package/dist/lib/utils.js +94 -0
- package/dist/livestream-provider/index.js +8 -3
- package/dist/livestream-store/chat.js +89 -65
- package/dist/livestream-store/index.js +1 -0
- package/dist/livestream-store/livestream-store.js +3 -0
- package/dist/livestream-store/stream-key.js +115 -0
- package/dist/player-store/player-provider.js +0 -1
- package/dist/player-store/player-store.js +13 -0
- package/dist/streamplace-store/block.js +23 -0
- package/dist/streamplace-store/index.js +1 -0
- package/dist/streamplace-store/stream.js +193 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/67b1eb60 +0 -0
- package/node-compile-cache/v22.15.0-x64-efe9a9df-0/7c275f90 +0 -0
- package/package.json +20 -4
- package/src/components/chat/chat-box.tsx +195 -0
- package/src/components/chat/chat-message.tsx +192 -0
- package/src/components/chat/chat.tsx +128 -0
- package/src/components/chat/mention-suggestions.tsx +71 -0
- package/src/components/chat/mod-view.tsx +118 -0
- package/src/components/mobile-player/fullscreen.native.tsx +193 -0
- package/src/components/mobile-player/fullscreen.tsx +79 -0
- package/src/components/mobile-player/player.tsx +134 -0
- package/src/components/mobile-player/props.tsx +11 -0
- package/src/components/mobile-player/shared.tsx +56 -0
- package/src/components/mobile-player/ui/countdown.tsx +119 -0
- package/src/components/mobile-player/ui/index.ts +5 -0
- package/src/components/mobile-player/ui/input.tsx +85 -0
- package/src/components/mobile-player/ui/metrics.tsx +69 -0
- package/src/components/mobile-player/ui/streamer-context-menu.tsx +3 -0
- package/src/components/mobile-player/ui/viewer-context-menu.tsx +70 -0
- package/src/components/mobile-player/use-webrtc.tsx +282 -0
- package/src/components/mobile-player/video.native.tsx +360 -0
- package/src/components/mobile-player/video.tsx +557 -0
- package/src/components/mobile-player/webrtc-diagnostics.tsx +149 -0
- package/src/components/mobile-player/webrtc-primitives.native.tsx +6 -0
- package/src/components/mobile-player/webrtc-primitives.tsx +33 -0
- package/src/components/ui/button.tsx +309 -0
- package/src/components/ui/dialog.tsx +376 -0
- package/src/components/ui/dropdown.tsx +399 -0
- package/src/components/ui/icons.tsx +50 -0
- package/src/components/ui/index.ts +33 -0
- package/src/components/ui/input.tsx +350 -0
- package/src/components/ui/loader.tsx +9 -0
- package/src/components/ui/primitives/button.tsx +292 -0
- package/src/components/ui/primitives/input.tsx +422 -0
- package/src/components/ui/primitives/modal.tsx +421 -0
- package/src/components/ui/primitives/text.tsx +499 -0
- package/src/components/ui/resizeable.tsx +169 -0
- package/src/components/ui/text.tsx +330 -0
- package/src/components/ui/textarea.tsx +34 -0
- package/src/components/ui/toast.tsx +203 -0
- package/src/components/ui/view.tsx +344 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useAvatars.tsx +44 -0
- package/src/hooks/useCameraToggle.ts +12 -0
- package/src/hooks/useKeyboard.tsx +41 -0
- package/src/hooks/useKeyboardSlide.ts +12 -0
- package/src/hooks/useLivestreamInfo.ts +67 -0
- package/src/hooks/useOuterAndInnerDimensions.tsx +32 -0
- package/src/hooks/usePlayerDimensions.ts +23 -0
- package/src/hooks/useSegmentTiming.tsx +88 -0
- package/src/index.tsx +21 -0
- package/src/lib/facet.ts +131 -0
- package/src/lib/theme/atoms.ts +760 -0
- package/src/lib/theme/atoms.types.ts +258 -0
- package/src/lib/theme/index.ts +48 -0
- package/src/lib/theme/theme.tsx +436 -0
- package/src/lib/theme/tokens.ts +409 -0
- package/src/lib/utils.ts +132 -0
- package/src/livestream-provider/index.tsx +13 -2
- package/src/livestream-store/chat.tsx +115 -78
- package/src/livestream-store/index.tsx +1 -0
- package/src/livestream-store/livestream-state.tsx +3 -0
- package/src/livestream-store/livestream-store.tsx +3 -0
- package/src/livestream-store/stream-key.tsx +124 -0
- package/src/player-store/player-provider.tsx +0 -1
- package/src/player-store/player-state.tsx +28 -0
- package/src/player-store/player-store.tsx +22 -0
- package/src/streamplace-store/block.tsx +29 -0
- package/src/streamplace-store/index.tsx +1 -0
- package/src/streamplace-store/stream.tsx +262 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import Animated, {
|
|
3
|
+
runOnJS,
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useFrameCallback,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withTiming,
|
|
8
|
+
} from "react-native-reanimated";
|
|
9
|
+
|
|
10
|
+
type CountdownOverlayProps = {
|
|
11
|
+
visible: boolean;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
startFrom?: number;
|
|
15
|
+
onDone?: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function CountdownOverlay({
|
|
19
|
+
visible,
|
|
20
|
+
width,
|
|
21
|
+
height,
|
|
22
|
+
startFrom = 3,
|
|
23
|
+
onDone,
|
|
24
|
+
}: CountdownOverlayProps) {
|
|
25
|
+
const [countdown, setCountdown] = useState(startFrom);
|
|
26
|
+
|
|
27
|
+
const startTimestamp = useSharedValue<number | null>(null);
|
|
28
|
+
const done = useSharedValue(false);
|
|
29
|
+
|
|
30
|
+
// Animation values
|
|
31
|
+
const scale = useSharedValue(1);
|
|
32
|
+
const opacity = useSharedValue(1);
|
|
33
|
+
|
|
34
|
+
const updateCountdown = (value: number) => {
|
|
35
|
+
setCountdown(value);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleDone = () => {
|
|
39
|
+
if (onDone) onDone();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Accurate countdown using useFrameCallback
|
|
43
|
+
useFrameCallback(({ timestamp }) => {
|
|
44
|
+
if (!visible) return;
|
|
45
|
+
|
|
46
|
+
// Set start timestamp on first frame
|
|
47
|
+
if (startTimestamp.value === null) {
|
|
48
|
+
startTimestamp.value = timestamp;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const elapsed = (timestamp - startTimestamp.value) / 1000; // Convert to seconds
|
|
53
|
+
const remaining = Math.max(0, startFrom - Math.floor(elapsed));
|
|
54
|
+
|
|
55
|
+
// Use runOnJS to call JavaScript functions from worklet
|
|
56
|
+
runOnJS(updateCountdown)(remaining);
|
|
57
|
+
|
|
58
|
+
if (remaining === 0 && !done.value) {
|
|
59
|
+
done.value = true;
|
|
60
|
+
runOnJS(handleDone)();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (visible) {
|
|
66
|
+
startTimestamp.value = null; // Will be set on first frame
|
|
67
|
+
setCountdown(startFrom);
|
|
68
|
+
done.value = false;
|
|
69
|
+
} else {
|
|
70
|
+
setCountdown(startFrom);
|
|
71
|
+
}
|
|
72
|
+
}, [visible, startFrom]);
|
|
73
|
+
|
|
74
|
+
// Animate scale and opacity on countdown change
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (visible && countdown > 0) {
|
|
77
|
+
scale.value = 1;
|
|
78
|
+
opacity.value = 1;
|
|
79
|
+
scale.value = withTiming(1.5, { duration: 1000 });
|
|
80
|
+
opacity.value = withTiming(0, { duration: 1000 });
|
|
81
|
+
}
|
|
82
|
+
}, [countdown, visible, scale, opacity]);
|
|
83
|
+
|
|
84
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
85
|
+
transform: [{ scale: scale.value }],
|
|
86
|
+
opacity: opacity.value,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
if (!visible || countdown === 0) return null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Animated.View
|
|
93
|
+
style={{
|
|
94
|
+
position: "absolute",
|
|
95
|
+
top: 0,
|
|
96
|
+
left: 0,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
backgroundColor: "rgba(0,0,0,0.7)",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
justifyContent: "center",
|
|
102
|
+
zIndex: 1000,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Animated.Text
|
|
106
|
+
style={[
|
|
107
|
+
{
|
|
108
|
+
color: "white",
|
|
109
|
+
fontSize: 120,
|
|
110
|
+
fontWeight: "bold",
|
|
111
|
+
},
|
|
112
|
+
animatedStyle,
|
|
113
|
+
]}
|
|
114
|
+
>
|
|
115
|
+
{typeof countdown === "number" ? countdown : ""}
|
|
116
|
+
</Animated.Text>
|
|
117
|
+
</Animated.View>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Keyboard, Pressable } from "react-native";
|
|
2
|
+
import { useKeyboardSlide } from "../../../hooks";
|
|
3
|
+
import * as atoms from "../../../lib/theme/atoms";
|
|
4
|
+
import { Input, Text, View } from "../../ui";
|
|
5
|
+
const { gap, h, layout, mt, p, position, px, py, sizes, w } = atoms;
|
|
6
|
+
|
|
7
|
+
type InputPanelProps = {
|
|
8
|
+
title: string | undefined;
|
|
9
|
+
setTitle: (title: string) => void;
|
|
10
|
+
ingestStarting: boolean;
|
|
11
|
+
toggleGoLive: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function InputPanel({
|
|
15
|
+
title,
|
|
16
|
+
setTitle,
|
|
17
|
+
ingestStarting,
|
|
18
|
+
toggleGoLive,
|
|
19
|
+
}: InputPanelProps) {
|
|
20
|
+
const { slideKeyboard } = useKeyboardSlide();
|
|
21
|
+
return (
|
|
22
|
+
<View
|
|
23
|
+
style={[
|
|
24
|
+
layout.position.absolute,
|
|
25
|
+
h.percent[30],
|
|
26
|
+
position.bottom[0],
|
|
27
|
+
w.percent[100],
|
|
28
|
+
layout.flex.center,
|
|
29
|
+
{ transform: [{ translateY: slideKeyboard }] },
|
|
30
|
+
]}
|
|
31
|
+
>
|
|
32
|
+
<View
|
|
33
|
+
style={[
|
|
34
|
+
layout.flex.column,
|
|
35
|
+
gap.all[2],
|
|
36
|
+
sizes.maxWidth[80],
|
|
37
|
+
{ padding: 10 },
|
|
38
|
+
]}
|
|
39
|
+
>
|
|
40
|
+
<View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}>
|
|
41
|
+
<Input
|
|
42
|
+
value={title}
|
|
43
|
+
onChange={setTitle}
|
|
44
|
+
placeholder="Enter stream title"
|
|
45
|
+
onEndEditing={Keyboard.dismiss}
|
|
46
|
+
/>
|
|
47
|
+
</View>
|
|
48
|
+
{ingestStarting ? (
|
|
49
|
+
<Text>Starting your stream...</Text>
|
|
50
|
+
) : (
|
|
51
|
+
<View style={[layout.flex.center]}>
|
|
52
|
+
<Pressable
|
|
53
|
+
onPress={toggleGoLive}
|
|
54
|
+
style={[
|
|
55
|
+
px[4],
|
|
56
|
+
py[2],
|
|
57
|
+
layout.flex.row,
|
|
58
|
+
layout.flex.center,
|
|
59
|
+
gap.all[1],
|
|
60
|
+
{
|
|
61
|
+
backgroundColor: "rgba(64,64,64, 0.8)",
|
|
62
|
+
borderRadius: 12,
|
|
63
|
+
},
|
|
64
|
+
]}
|
|
65
|
+
>
|
|
66
|
+
<View
|
|
67
|
+
style={[
|
|
68
|
+
p[2],
|
|
69
|
+
{
|
|
70
|
+
backgroundColor: "rgba(256,0,0, 0.8)",
|
|
71
|
+
borderRadius: 12,
|
|
72
|
+
},
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
<Text center>Go Live</Text>
|
|
76
|
+
</Pressable>
|
|
77
|
+
<Text color="muted" size="xs" style={[mt[2]]}>
|
|
78
|
+
We'll announce that you're live on Bluesky.
|
|
79
|
+
</Text>
|
|
80
|
+
</View>
|
|
81
|
+
)}
|
|
82
|
+
</View>
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { AlertCircle, CircleCheck, CircleX } from "lucide-react-native";
|
|
2
|
+
import { useSegmentTiming } from "../../../hooks/useSegmentTiming";
|
|
3
|
+
import * as atoms from "../../../lib/theme/atoms";
|
|
4
|
+
import { Text, View } from "../../ui";
|
|
5
|
+
|
|
6
|
+
type MetricsPanelProps = {
|
|
7
|
+
showMetrics: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function MetricsPanel({ showMetrics }: MetricsPanelProps) {
|
|
11
|
+
const { connectionQuality, segmentDeltas, mean, range } = useSegmentTiming();
|
|
12
|
+
|
|
13
|
+
let icon = <CircleX color="#d44" />;
|
|
14
|
+
let color = "#d44";
|
|
15
|
+
if (connectionQuality === "good") {
|
|
16
|
+
icon = <CircleCheck color="#4d4" />;
|
|
17
|
+
color = "#4d4";
|
|
18
|
+
} else if (connectionQuality === "degraded") {
|
|
19
|
+
icon = <AlertCircle color="#aa4" />;
|
|
20
|
+
color = "#aa4";
|
|
21
|
+
} else {
|
|
22
|
+
icon = <CircleX color="#d44" />;
|
|
23
|
+
color = "#d44";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View
|
|
28
|
+
style={{
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
gap: 8,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<View
|
|
34
|
+
style={{
|
|
35
|
+
flexDirection: "row",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
padding: 10,
|
|
38
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
39
|
+
borderRadius: 8,
|
|
40
|
+
gap: 4,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{icon}
|
|
44
|
+
<Text
|
|
45
|
+
style={[
|
|
46
|
+
atoms.pt[0],
|
|
47
|
+
{
|
|
48
|
+
color,
|
|
49
|
+
},
|
|
50
|
+
]}
|
|
51
|
+
>
|
|
52
|
+
{connectionQuality.toUpperCase()}
|
|
53
|
+
</Text>
|
|
54
|
+
</View>
|
|
55
|
+
{showMetrics && (
|
|
56
|
+
<View>
|
|
57
|
+
<Text>
|
|
58
|
+
last Δ:{" "}
|
|
59
|
+
{segmentDeltas.length > 0
|
|
60
|
+
? segmentDeltas[segmentDeltas.length - 1]
|
|
61
|
+
: "—"}
|
|
62
|
+
</Text>
|
|
63
|
+
<Text>mean: {mean}</Text>
|
|
64
|
+
<Text>range: {range}</Text>
|
|
65
|
+
</View>
|
|
66
|
+
)}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Menu } from "lucide-react-native";
|
|
2
|
+
import { colors } from "../../../lib/theme";
|
|
3
|
+
import { useLivestreamStore } from "../../../livestream-store";
|
|
4
|
+
import { PlayerProtocol, usePlayerStore } from "../../../player-store/";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuCheckboxItem,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuInfo,
|
|
10
|
+
DropdownMenuRadioGroup,
|
|
11
|
+
DropdownMenuRadioItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
ResponsiveDropdownMenuContent,
|
|
14
|
+
Text,
|
|
15
|
+
} from "../../ui";
|
|
16
|
+
|
|
17
|
+
export function ContextMenu() {
|
|
18
|
+
const quality = usePlayerStore((x) => x.selectedRendition);
|
|
19
|
+
const setQuality = usePlayerStore((x) => x.setSelectedRendition);
|
|
20
|
+
const qualities = useLivestreamStore((x) => x.renditions);
|
|
21
|
+
|
|
22
|
+
const protocol = usePlayerStore((x) => x.protocol);
|
|
23
|
+
const setProtocol = usePlayerStore((x) => x.setProtocol);
|
|
24
|
+
|
|
25
|
+
const debugInfo = usePlayerStore((x) => x.showDebugInfo);
|
|
26
|
+
const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo);
|
|
27
|
+
|
|
28
|
+
const lowLatency = protocol === "webrtc";
|
|
29
|
+
const setLowLatency = (value: boolean) => {
|
|
30
|
+
setProtocol(value ? PlayerProtocol.WEBRTC : PlayerProtocol.HLS);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<DropdownMenu>
|
|
35
|
+
<DropdownMenuTrigger>
|
|
36
|
+
<Menu size={32} color={colors.gray[200]} />
|
|
37
|
+
</DropdownMenuTrigger>
|
|
38
|
+
<ResponsiveDropdownMenuContent>
|
|
39
|
+
<DropdownMenuGroup title="Resolution">
|
|
40
|
+
<DropdownMenuRadioGroup value={quality} onValueChange={setQuality}>
|
|
41
|
+
<DropdownMenuRadioItem value="source">
|
|
42
|
+
<Text>Source</Text>
|
|
43
|
+
</DropdownMenuRadioItem>
|
|
44
|
+
{qualities.map((r) => (
|
|
45
|
+
<DropdownMenuRadioItem value={r.name}>
|
|
46
|
+
<Text>{r.name}</Text>
|
|
47
|
+
</DropdownMenuRadioItem>
|
|
48
|
+
))}
|
|
49
|
+
</DropdownMenuRadioGroup>
|
|
50
|
+
</DropdownMenuGroup>
|
|
51
|
+
<DropdownMenuGroup title="Advanced">
|
|
52
|
+
<DropdownMenuCheckboxItem
|
|
53
|
+
checked={lowLatency}
|
|
54
|
+
onCheckedChange={() => setLowLatency(!lowLatency)}
|
|
55
|
+
>
|
|
56
|
+
<Text>Low Latency</Text>
|
|
57
|
+
</DropdownMenuCheckboxItem>
|
|
58
|
+
<DropdownMenuInfo description="Lowers the delay between video and chat messages." />
|
|
59
|
+
<DropdownMenuCheckboxItem
|
|
60
|
+
checked={debugInfo}
|
|
61
|
+
onCheckedChange={() => setShowDebugInfo(!debugInfo)}
|
|
62
|
+
>
|
|
63
|
+
<Text>Segment Debug Info</Text>
|
|
64
|
+
</DropdownMenuCheckboxItem>
|
|
65
|
+
</DropdownMenuGroup>
|
|
66
|
+
<DropdownMenuInfo description="Lowers the delay between video and chat messages." />
|
|
67
|
+
</ResponsiveDropdownMenuContent>
|
|
68
|
+
</DropdownMenu>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { usePlayerStore, useStreamKey } from "../..";
|
|
3
|
+
import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives";
|
|
4
|
+
|
|
5
|
+
export default function useWebRTC(
|
|
6
|
+
endpoint: string,
|
|
7
|
+
): [MediaStream | null, boolean] {
|
|
8
|
+
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
|
9
|
+
const [stuck, setStuck] = useState<boolean>(false);
|
|
10
|
+
|
|
11
|
+
const lastChange = useRef<number>(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const peerConnection = new RTCPeerConnection({
|
|
15
|
+
bundlePolicy: "max-bundle",
|
|
16
|
+
});
|
|
17
|
+
peerConnection.addTransceiver("video", {
|
|
18
|
+
direction: "recvonly",
|
|
19
|
+
});
|
|
20
|
+
peerConnection.addTransceiver("audio", {
|
|
21
|
+
direction: "recvonly",
|
|
22
|
+
});
|
|
23
|
+
peerConnection.addEventListener("track", (event) => {
|
|
24
|
+
const track = event.track;
|
|
25
|
+
if (!track) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
setMediaStream(event.streams[0]);
|
|
29
|
+
});
|
|
30
|
+
peerConnection.addEventListener("connectionstatechange", () => {
|
|
31
|
+
console.log("connection state change", peerConnection.connectionState);
|
|
32
|
+
if (peerConnection.connectionState === "closed") {
|
|
33
|
+
setStuck(true);
|
|
34
|
+
}
|
|
35
|
+
if (peerConnection.connectionState !== "connected") {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
peerConnection.addEventListener("negotiationneeded", () => {
|
|
40
|
+
negotiateConnectionWithClientOffer(peerConnection, endpoint);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let lastFramesReceived = 0;
|
|
44
|
+
let lastAudioFramesReceived = 0;
|
|
45
|
+
|
|
46
|
+
const handle = setInterval(async () => {
|
|
47
|
+
const stats = await peerConnection.getStats();
|
|
48
|
+
stats.forEach((stat) => {
|
|
49
|
+
const mediaType = stat.mediaType /* web */ ?? stat.kind; /* native */
|
|
50
|
+
if (stat.type === "inbound-rtp" && mediaType === "audio") {
|
|
51
|
+
const audioFramesReceived = stat.lastPacketReceivedTimestamp;
|
|
52
|
+
if (lastAudioFramesReceived !== audioFramesReceived) {
|
|
53
|
+
lastAudioFramesReceived = audioFramesReceived;
|
|
54
|
+
lastChange.current = Date.now();
|
|
55
|
+
setStuck(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (stat.type === "inbound-rtp" && mediaType === "video") {
|
|
59
|
+
const framesReceived = stat.framesReceived;
|
|
60
|
+
if (lastFramesReceived !== framesReceived) {
|
|
61
|
+
lastFramesReceived = framesReceived;
|
|
62
|
+
lastChange.current = Date.now();
|
|
63
|
+
setStuck(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (Date.now() - lastChange.current > 2000) {
|
|
68
|
+
setStuck(true);
|
|
69
|
+
}
|
|
70
|
+
}, 200);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
clearInterval(handle);
|
|
74
|
+
peerConnection.close();
|
|
75
|
+
};
|
|
76
|
+
}, [endpoint]);
|
|
77
|
+
return [mediaStream, stuck];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Performs the actual SDP exchange.
|
|
82
|
+
*
|
|
83
|
+
* 1. Constructs the client's SDP offer
|
|
84
|
+
* 2. Sends the SDP offer to the server,
|
|
85
|
+
* 3. Awaits the server's offer.
|
|
86
|
+
*
|
|
87
|
+
* SDP describes what kind of media we can send and how the server and client communicate.
|
|
88
|
+
*
|
|
89
|
+
* https://developer.mozilla.org/en-US/docs/Glossary/SDP
|
|
90
|
+
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation
|
|
91
|
+
*/
|
|
92
|
+
export async function negotiateConnectionWithClientOffer(
|
|
93
|
+
peerConnection: RTCPeerConnection,
|
|
94
|
+
endpoint: string,
|
|
95
|
+
bearerToken?: string,
|
|
96
|
+
) {
|
|
97
|
+
/** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */
|
|
98
|
+
const offer = await peerConnection.createOffer({
|
|
99
|
+
offerToReceiveAudio: true,
|
|
100
|
+
offerToReceiveVideo: true,
|
|
101
|
+
});
|
|
102
|
+
/** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */
|
|
103
|
+
await peerConnection.setLocalDescription(offer);
|
|
104
|
+
|
|
105
|
+
/** Wait for ICE gathering to complete */
|
|
106
|
+
let ofr = await waitToCompleteICEGathering(peerConnection);
|
|
107
|
+
if (!ofr) {
|
|
108
|
+
throw Error("failed to gather ICE candidates for offer");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* As long as the connection is open, attempt to...
|
|
113
|
+
*/
|
|
114
|
+
while (peerConnection.connectionState !== "closed") {
|
|
115
|
+
try {
|
|
116
|
+
/**
|
|
117
|
+
* This response contains the server's SDP offer.
|
|
118
|
+
* This specifies how the client should communicate,
|
|
119
|
+
* and what kind of media client and server have negotiated to exchange.
|
|
120
|
+
*/
|
|
121
|
+
let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken);
|
|
122
|
+
if (response.status === 201) {
|
|
123
|
+
let answerSDP = await response.text();
|
|
124
|
+
if ((peerConnection.connectionState as string) === "closed") {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await peerConnection.setRemoteDescription(
|
|
128
|
+
new RTCSessionDescription({ type: "answer", sdp: answerSDP }),
|
|
129
|
+
);
|
|
130
|
+
return response.headers.get("Location");
|
|
131
|
+
} else if (response.status === 405) {
|
|
132
|
+
console.log(
|
|
133
|
+
"Remember to update the URL passed into the WHIP or WHEP client",
|
|
134
|
+
);
|
|
135
|
+
} else {
|
|
136
|
+
const errorMessage = await response.text();
|
|
137
|
+
console.error(errorMessage);
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error(`posting sdp offer failed: ${e}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Limit reconnection attempts to at-most once every 5 seconds */
|
|
144
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function postSDPOffer(
|
|
149
|
+
endpoint: string,
|
|
150
|
+
data: string,
|
|
151
|
+
bearerToken?: string,
|
|
152
|
+
) {
|
|
153
|
+
return await fetch(endpoint, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
mode: "cors",
|
|
156
|
+
headers: {
|
|
157
|
+
"content-type": "application/sdp",
|
|
158
|
+
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
159
|
+
},
|
|
160
|
+
body: data,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Receives an RTCPeerConnection and waits until
|
|
166
|
+
* the connection is initialized or a timeout passes.
|
|
167
|
+
*
|
|
168
|
+
* https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#section-4.1
|
|
169
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState
|
|
170
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icegatheringstatechange_event
|
|
171
|
+
*/
|
|
172
|
+
async function waitToCompleteICEGathering(peerConnection: RTCPeerConnection) {
|
|
173
|
+
return new Promise<RTCSessionDescription | null>((resolve) => {
|
|
174
|
+
/** Wait at most 1 second for ICE gathering. */
|
|
175
|
+
setTimeout(function () {
|
|
176
|
+
if (peerConnection.connectionState === "closed") {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
resolve(peerConnection.localDescription);
|
|
180
|
+
}, 1000);
|
|
181
|
+
peerConnection.addEventListener("icegatheringstatechange", (ev) => {
|
|
182
|
+
if (peerConnection.iceGatheringState === "complete") {
|
|
183
|
+
resolve(peerConnection.localDescription);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function useWebRTCIngest({
|
|
190
|
+
endpoint,
|
|
191
|
+
}: {
|
|
192
|
+
endpoint: string;
|
|
193
|
+
}): [MediaStream | null, (mediaStream: MediaStream | null) => void] {
|
|
194
|
+
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
|
195
|
+
const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState);
|
|
196
|
+
const setIngestConnectionState = usePlayerStore(
|
|
197
|
+
(x) => x.setIngestConnectionState,
|
|
198
|
+
);
|
|
199
|
+
const storedKey = useStreamKey();
|
|
200
|
+
const [peerConnection, setPeerConnection] =
|
|
201
|
+
useState<RTCPeerConnection | null>(null);
|
|
202
|
+
|
|
203
|
+
const videoTransceiver = useRef<RTCRtpTransceiver | null>(null);
|
|
204
|
+
const audioTransceiver = useRef<RTCRtpTransceiver | null>(null);
|
|
205
|
+
|
|
206
|
+
const [retryTime, setRetryTime] = useState<number>(0);
|
|
207
|
+
const ingestLive = usePlayerStore((x) => x.ingestLive);
|
|
208
|
+
|
|
209
|
+
// "Outer loop": when we need a new peer connection, this sets that up
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!storedKey) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!ingestLive) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const peerConnection = new RTCPeerConnection({
|
|
218
|
+
bundlePolicy: "max-bundle",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
videoTransceiver.current = peerConnection.addTransceiver("video", {
|
|
222
|
+
direction: "sendonly",
|
|
223
|
+
});
|
|
224
|
+
audioTransceiver.current = peerConnection.addTransceiver("audio", {
|
|
225
|
+
direction: "sendonly",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
peerConnection.addEventListener("connectionstatechange", (ev) => {
|
|
229
|
+
setIngestConnectionState(peerConnection.connectionState);
|
|
230
|
+
console.log("connection state change", peerConnection.connectionState);
|
|
231
|
+
if (peerConnection.connectionState === "failed") {
|
|
232
|
+
setRetryTime(Date.now());
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
peerConnection.addEventListener("negotiationneeded", (ev) => {
|
|
236
|
+
negotiateConnectionWithClientOffer(
|
|
237
|
+
peerConnection,
|
|
238
|
+
endpoint,
|
|
239
|
+
storedKey.streamKey?.privateKey,
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
peerConnection.addEventListener("track", (ev) => {
|
|
244
|
+
console.log(ev);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
setPeerConnection(peerConnection);
|
|
248
|
+
|
|
249
|
+
return () => {
|
|
250
|
+
peerConnection.close();
|
|
251
|
+
};
|
|
252
|
+
}, [endpoint, storedKey.streamKey?.privateKey, retryTime, ingestLive]);
|
|
253
|
+
|
|
254
|
+
// "Inner loop": when our tracks change, we update the transceivers
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!mediaStream) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!peerConnection) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!ingestLive) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const track of mediaStream.getTracks()) {
|
|
266
|
+
console.log(
|
|
267
|
+
"adding track",
|
|
268
|
+
track.kind,
|
|
269
|
+
track.label,
|
|
270
|
+
track.enabled,
|
|
271
|
+
track.readyState,
|
|
272
|
+
);
|
|
273
|
+
if (track.kind === "video") {
|
|
274
|
+
videoTransceiver.current?.sender?.replaceTrack(track);
|
|
275
|
+
} else if (track.kind === "audio") {
|
|
276
|
+
audioTransceiver.current?.sender?.replaceTrack(track);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}, [peerConnection, mediaStream, ingestLive]);
|
|
280
|
+
|
|
281
|
+
return [mediaStream, setMediaStream];
|
|
282
|
+
}
|