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