@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.
Files changed (138) hide show
  1. package/dist/components/chat/chat-box.js +109 -0
  2. package/dist/components/chat/chat-message.js +76 -0
  3. package/dist/components/chat/chat.js +56 -0
  4. package/dist/components/chat/mention-suggestions.js +39 -0
  5. package/dist/components/chat/mod-view.js +33 -0
  6. package/dist/components/mobile-player/fullscreen.js +69 -0
  7. package/dist/components/mobile-player/fullscreen.native.js +151 -0
  8. package/dist/components/mobile-player/player.js +103 -0
  9. package/dist/components/mobile-player/props.js +1 -0
  10. package/dist/components/mobile-player/shared.js +51 -0
  11. package/dist/components/mobile-player/ui/countdown.js +79 -0
  12. package/dist/components/mobile-player/ui/index.js +5 -0
  13. package/dist/components/mobile-player/ui/input.js +38 -0
  14. package/dist/components/mobile-player/ui/metrics.js +40 -0
  15. package/dist/components/mobile-player/ui/streamer-context-menu.js +4 -0
  16. package/dist/components/mobile-player/ui/viewer-context-menu.js +20 -0
  17. package/dist/components/mobile-player/use-webrtc.js +232 -0
  18. package/dist/components/mobile-player/video.js +375 -0
  19. package/dist/components/mobile-player/video.native.js +238 -0
  20. package/dist/components/mobile-player/webrtc-diagnostics.js +106 -0
  21. package/dist/components/mobile-player/webrtc-primitives.js +25 -0
  22. package/dist/components/mobile-player/webrtc-primitives.native.js +1 -0
  23. package/dist/components/ui/button.js +220 -0
  24. package/dist/components/ui/dialog.js +203 -0
  25. package/dist/components/ui/dropdown.js +148 -0
  26. package/dist/components/ui/icons.js +22 -0
  27. package/dist/components/ui/index.js +22 -0
  28. package/dist/components/ui/input.js +202 -0
  29. package/dist/components/ui/loader.js +7 -0
  30. package/dist/components/ui/primitives/button.js +121 -0
  31. package/dist/components/ui/primitives/input.js +202 -0
  32. package/dist/components/ui/primitives/modal.js +203 -0
  33. package/dist/components/ui/primitives/text.js +286 -0
  34. package/dist/components/ui/resizeable.js +101 -0
  35. package/dist/components/ui/text.js +175 -0
  36. package/dist/components/ui/textarea.js +17 -0
  37. package/dist/components/ui/toast.js +129 -0
  38. package/dist/components/ui/view.js +250 -0
  39. package/dist/hooks/index.js +9 -0
  40. package/dist/hooks/useAvatars.js +32 -0
  41. package/dist/hooks/useCameraToggle.js +9 -0
  42. package/dist/hooks/useKeyboard.js +33 -0
  43. package/dist/hooks/useKeyboardSlide.js +11 -0
  44. package/dist/hooks/useLivestreamInfo.js +62 -0
  45. package/dist/hooks/useOuterAndInnerDimensions.js +27 -0
  46. package/dist/hooks/usePlayerDimensions.js +19 -0
  47. package/dist/hooks/useSegmentTiming.js +62 -0
  48. package/dist/index.js +10 -0
  49. package/dist/lib/facet.js +88 -0
  50. package/dist/lib/theme/atoms.js +620 -0
  51. package/dist/lib/theme/atoms.types.js +5 -0
  52. package/dist/lib/theme/index.js +9 -0
  53. package/dist/lib/theme/theme.js +248 -0
  54. package/dist/lib/theme/tokens.js +383 -0
  55. package/dist/lib/utils.js +94 -0
  56. package/dist/livestream-provider/index.js +8 -3
  57. package/dist/livestream-store/chat.js +89 -65
  58. package/dist/livestream-store/index.js +1 -0
  59. package/dist/livestream-store/livestream-store.js +3 -0
  60. package/dist/livestream-store/stream-key.js +115 -0
  61. package/dist/player-store/player-provider.js +0 -1
  62. package/dist/player-store/player-store.js +13 -0
  63. package/dist/streamplace-store/block.js +23 -0
  64. package/dist/streamplace-store/index.js +1 -0
  65. package/dist/streamplace-store/stream.js +193 -0
  66. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  67. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
  68. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/67b1eb60 +0 -0
  69. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/7c275f90 +0 -0
  70. package/package.json +20 -4
  71. package/src/components/chat/chat-box.tsx +195 -0
  72. package/src/components/chat/chat-message.tsx +192 -0
  73. package/src/components/chat/chat.tsx +128 -0
  74. package/src/components/chat/mention-suggestions.tsx +71 -0
  75. package/src/components/chat/mod-view.tsx +118 -0
  76. package/src/components/mobile-player/fullscreen.native.tsx +193 -0
  77. package/src/components/mobile-player/fullscreen.tsx +79 -0
  78. package/src/components/mobile-player/player.tsx +134 -0
  79. package/src/components/mobile-player/props.tsx +11 -0
  80. package/src/components/mobile-player/shared.tsx +56 -0
  81. package/src/components/mobile-player/ui/countdown.tsx +119 -0
  82. package/src/components/mobile-player/ui/index.ts +5 -0
  83. package/src/components/mobile-player/ui/input.tsx +85 -0
  84. package/src/components/mobile-player/ui/metrics.tsx +69 -0
  85. package/src/components/mobile-player/ui/streamer-context-menu.tsx +3 -0
  86. package/src/components/mobile-player/ui/viewer-context-menu.tsx +70 -0
  87. package/src/components/mobile-player/use-webrtc.tsx +282 -0
  88. package/src/components/mobile-player/video.native.tsx +360 -0
  89. package/src/components/mobile-player/video.tsx +557 -0
  90. package/src/components/mobile-player/webrtc-diagnostics.tsx +149 -0
  91. package/src/components/mobile-player/webrtc-primitives.native.tsx +6 -0
  92. package/src/components/mobile-player/webrtc-primitives.tsx +33 -0
  93. package/src/components/ui/button.tsx +309 -0
  94. package/src/components/ui/dialog.tsx +376 -0
  95. package/src/components/ui/dropdown.tsx +399 -0
  96. package/src/components/ui/icons.tsx +50 -0
  97. package/src/components/ui/index.ts +33 -0
  98. package/src/components/ui/input.tsx +350 -0
  99. package/src/components/ui/loader.tsx +9 -0
  100. package/src/components/ui/primitives/button.tsx +292 -0
  101. package/src/components/ui/primitives/input.tsx +422 -0
  102. package/src/components/ui/primitives/modal.tsx +421 -0
  103. package/src/components/ui/primitives/text.tsx +499 -0
  104. package/src/components/ui/resizeable.tsx +169 -0
  105. package/src/components/ui/text.tsx +330 -0
  106. package/src/components/ui/textarea.tsx +34 -0
  107. package/src/components/ui/toast.tsx +203 -0
  108. package/src/components/ui/view.tsx +344 -0
  109. package/src/hooks/index.ts +9 -0
  110. package/src/hooks/useAvatars.tsx +44 -0
  111. package/src/hooks/useCameraToggle.ts +12 -0
  112. package/src/hooks/useKeyboard.tsx +41 -0
  113. package/src/hooks/useKeyboardSlide.ts +12 -0
  114. package/src/hooks/useLivestreamInfo.ts +67 -0
  115. package/src/hooks/useOuterAndInnerDimensions.tsx +32 -0
  116. package/src/hooks/usePlayerDimensions.ts +23 -0
  117. package/src/hooks/useSegmentTiming.tsx +88 -0
  118. package/src/index.tsx +21 -0
  119. package/src/lib/facet.ts +131 -0
  120. package/src/lib/theme/atoms.ts +760 -0
  121. package/src/lib/theme/atoms.types.ts +258 -0
  122. package/src/lib/theme/index.ts +48 -0
  123. package/src/lib/theme/theme.tsx +436 -0
  124. package/src/lib/theme/tokens.ts +409 -0
  125. package/src/lib/utils.ts +132 -0
  126. package/src/livestream-provider/index.tsx +13 -2
  127. package/src/livestream-store/chat.tsx +115 -78
  128. package/src/livestream-store/index.tsx +1 -0
  129. package/src/livestream-store/livestream-state.tsx +3 -0
  130. package/src/livestream-store/livestream-store.tsx +3 -0
  131. package/src/livestream-store/stream-key.tsx +124 -0
  132. package/src/player-store/player-provider.tsx +0 -1
  133. package/src/player-store/player-state.tsx +28 -0
  134. package/src/player-store/player-store.tsx +22 -0
  135. package/src/streamplace-store/block.tsx +29 -0
  136. package/src/streamplace-store/index.tsx +1 -0
  137. package/src/streamplace-store/stream.tsx +262 -0
  138. 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,5 @@
1
+ export * from "./countdown";
2
+ export * from "./input";
3
+ export * from "./metrics";
4
+ export * from "./streamer-context-menu";
5
+ export * from "./viewer-context-menu";
@@ -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,3 @@
1
+ export function StreamContextMenu() {
2
+ return <></>;
3
+ }
@@ -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
+ }