@streamplace/components 0.7.2 → 0.7.7

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 (91) hide show
  1. package/dist/components/chat/chat-box.js +212 -24
  2. package/dist/components/chat/chat-message.js +5 -5
  3. package/dist/components/chat/chat.js +83 -5
  4. package/dist/components/chat/emoji-suggestions.js +35 -0
  5. package/dist/components/chat/mod-view.js +59 -8
  6. package/dist/components/chat/system-message.js +19 -0
  7. package/dist/components/icons/bluesky-icon.js +9 -0
  8. package/dist/components/keep-awake.js +7 -0
  9. package/dist/components/keep-awake.native.js +16 -0
  10. package/dist/components/mobile-player/fullscreen.js +2 -1
  11. package/dist/components/mobile-player/fullscreen.native.js +3 -3
  12. package/dist/components/mobile-player/player.js +15 -30
  13. package/dist/components/mobile-player/ui/index.js +2 -0
  14. package/dist/components/mobile-player/ui/report-modal.js +90 -0
  15. package/dist/components/mobile-player/ui/streamer-loading-overlay.js +104 -0
  16. package/dist/components/mobile-player/ui/viewer-context-menu.js +20 -1
  17. package/dist/components/mobile-player/ui/viewer-loading-overlay.js +49 -0
  18. package/dist/components/mobile-player/use-webrtc.js +7 -1
  19. package/dist/components/mobile-player/video-retry.js +29 -0
  20. package/dist/components/mobile-player/video.js +84 -9
  21. package/dist/components/mobile-player/video.native.js +24 -10
  22. package/dist/components/share/sharesheet.js +91 -0
  23. package/dist/components/ui/dialog.js +1 -1
  24. package/dist/components/ui/dropdown.js +6 -6
  25. package/dist/components/ui/index.js +2 -0
  26. package/dist/components/ui/primitives/modal.js +0 -1
  27. package/dist/components/ui/resizeable.js +20 -11
  28. package/dist/components/ui/slider.js +5 -0
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/usePointerDevice.js +71 -0
  31. package/dist/index.js +10 -3
  32. package/dist/lib/system-messages.js +101 -0
  33. package/dist/livestream-store/chat.js +111 -18
  34. package/dist/livestream-store/livestream-store.js +3 -0
  35. package/dist/livestream-store/problems.js +76 -0
  36. package/dist/livestream-store/websocket-consumer.js +39 -4
  37. package/dist/player-store/player-store.js +33 -4
  38. package/dist/streamplace-store/block.js +51 -12
  39. package/dist/streamplace-store/stream.js +44 -23
  40. package/dist/ui/index.js +79 -0
  41. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  42. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
  43. package/node-compile-cache/{v22.15.0-x64-92db9086-0 → v22.15.0-x64-efe9a9df-0}/67b1eb60 +0 -0
  44. package/node-compile-cache/{v22.15.0-x64-92db9086-0 → v22.15.0-x64-efe9a9df-0}/7c275f90 +0 -0
  45. package/package.json +6 -2
  46. package/src/components/chat/chat-box.tsx +295 -25
  47. package/src/components/chat/chat-message.tsx +6 -7
  48. package/src/components/chat/chat.tsx +192 -41
  49. package/src/components/chat/emoji-suggestions.tsx +94 -0
  50. package/src/components/chat/mod-view.tsx +119 -40
  51. package/src/components/chat/system-message.tsx +38 -0
  52. package/src/components/icons/bluesky-icon.tsx +9 -0
  53. package/src/components/keep-awake.native.tsx +13 -0
  54. package/src/components/keep-awake.tsx +3 -0
  55. package/src/components/mobile-player/fullscreen.native.tsx +12 -3
  56. package/src/components/mobile-player/fullscreen.tsx +10 -3
  57. package/src/components/mobile-player/player.tsx +28 -36
  58. package/src/components/mobile-player/props.tsx +1 -0
  59. package/src/components/mobile-player/ui/index.ts +2 -0
  60. package/src/components/mobile-player/ui/report-modal.tsx +195 -0
  61. package/src/components/mobile-player/ui/streamer-loading-overlay.tsx +154 -0
  62. package/src/components/mobile-player/ui/viewer-context-menu.tsx +31 -3
  63. package/src/components/mobile-player/ui/viewer-loading-overlay.tsx +66 -0
  64. package/src/components/mobile-player/use-webrtc.tsx +10 -2
  65. package/src/components/mobile-player/video-retry.tsx +28 -0
  66. package/src/components/mobile-player/video.native.tsx +24 -10
  67. package/src/components/mobile-player/video.tsx +100 -21
  68. package/src/components/share/sharesheet.tsx +185 -0
  69. package/src/components/ui/dialog.tsx +1 -1
  70. package/src/components/ui/dropdown.tsx +13 -13
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/primitives/modal.tsx +0 -1
  73. package/src/components/ui/resizeable.tsx +26 -15
  74. package/src/components/ui/slider.tsx +1 -0
  75. package/src/hooks/index.ts +1 -0
  76. package/src/hooks/usePointerDevice.ts +89 -0
  77. package/src/index.tsx +11 -2
  78. package/src/lib/system-messages.ts +135 -0
  79. package/src/livestream-store/chat.tsx +145 -17
  80. package/src/livestream-store/livestream-state.tsx +10 -0
  81. package/src/livestream-store/livestream-store.tsx +3 -0
  82. package/src/livestream-store/problems.tsx +96 -0
  83. package/src/livestream-store/websocket-consumer.tsx +44 -4
  84. package/src/player-store/player-state.tsx +25 -4
  85. package/src/player-store/player-store.tsx +43 -5
  86. package/src/streamplace-store/block.tsx +55 -13
  87. package/src/streamplace-store/stream.tsx +66 -35
  88. package/src/ui/index.ts +86 -0
  89. package/tsconfig.tsbuildinfo +1 -1
  90. package/node-compile-cache/v22.15.0-x64-92db9086-0/37be0eec +0 -0
  91. package/node-compile-cache/v22.15.0-x64-92db9086-0/56540125 +0 -0
@@ -4,13 +4,18 @@ import { useEffect, useRef, useState } from "react";
4
4
  import { BackHandler, Dimensions, StyleSheet, View } from "react-native";
5
5
  import { SystemBars } from "react-native-edge-to-edge";
6
6
  import { useSafeAreaInsets } from "react-native-safe-area-context";
7
- import { PlayerProtocol, useLivestreamStore, usePlayerStore } from "../..";
7
+ import {
8
+ PlayerProtocol,
9
+ useLivestreamStore,
10
+ usePlayerStore,
11
+ VideoRetry,
12
+ } from "../..";
8
13
  import Video from "./video.native";
9
14
 
10
15
  // Standard 16:9 video aspect ratio
11
16
  const VIDEO_ASPECT_RATIO = 16 / 9;
12
17
 
13
- export function Fullscreen(props: { src: string }) {
18
+ export function Fullscreen(props: { src: string; children?: React.ReactNode }) {
14
19
  const ref = useRef<VideoView>(null);
15
20
  const insets = useSafeAreaInsets();
16
21
  const navigation = useNavigation();
@@ -157,6 +162,7 @@ export function Fullscreen(props: { src: string }) {
157
162
  ]}
158
163
  >
159
164
  <Video />
165
+ {props.children}
160
166
  </View>
161
167
  </View>
162
168
  );
@@ -165,7 +171,10 @@ export function Fullscreen(props: { src: string }) {
165
171
  // Normal non-fullscreen mode
166
172
  return (
167
173
  <>
168
- <Video />
174
+ <VideoRetry>
175
+ <Video />
176
+ </VideoRetry>
177
+ {props.children}
169
178
  </>
170
179
  );
171
180
  }
@@ -3,8 +3,9 @@ import { View as RNView } from "react-native";
3
3
  import { getFirstPlayerID, usePlayerStore } from "../..";
4
4
  import { View } from "../../components/ui";
5
5
  import Video from "./video";
6
+ import VideoRetry from "./video-retry";
6
7
 
7
- export function Fullscreen(props: { src: string }) {
8
+ export function Fullscreen(props: { src: string; children?: React.ReactNode }) {
8
9
  const playerId = getFirstPlayerID();
9
10
  const protocol = usePlayerStore((x) => x.protocol, playerId);
10
11
  const fullscreen = usePlayerStore((x) => x.fullscreen, playerId);
@@ -72,8 +73,14 @@ export function Fullscreen(props: { src: string }) {
72
73
  }, []);
73
74
 
74
75
  return (
75
- <View ref={divRef}>
76
- <Video />
76
+ <View
77
+ ref={divRef}
78
+ style={{ width: "100%", height: "100%", overflow: "hidden" }}
79
+ >
80
+ <VideoRetry>
81
+ <Video />
82
+ </VideoRetry>
83
+ {props.children}
77
84
  </View>
78
85
  );
79
86
  }
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
- import { flex, layout, w, zIndex } from "../../lib/theme/atoms";
3
- import { useSegment } from "../../livestream-store";
2
+ import { flex, h, layout, w, zIndex } from "../../lib/theme/atoms";
4
3
  import {
5
4
  PlayerStatus,
6
5
  PlayerStatusTracker,
@@ -10,19 +9,29 @@ import { useStreamplaceStore } from "../../streamplace-store";
10
9
  import { Text, View } from "../ui";
11
10
  import { Fullscreen } from "./fullscreen";
12
11
  import { PlayerProps } from "./props";
12
+ import ReportModal from "./ui/report-modal";
13
13
 
14
14
  const OFFLINE_THRESHOLD = 10000;
15
15
 
16
16
  export * as PlayerUI from "./ui";
17
17
 
18
- export function Player(props: Partial<PlayerProps>) {
19
- const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
20
-
21
- const setOffline = usePlayerStore((x) => x.setOffline);
18
+ export function Player(
19
+ props: Partial<PlayerProps> & { children?: React.ReactNode },
20
+ ) {
22
21
  const setIngest = usePlayerStore((x) => x.setIngestConnectionState);
23
22
 
24
23
  const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout);
25
24
 
25
+ const setReportingURL = usePlayerStore((x) => x.setReportingURL);
26
+
27
+ const reportModalOpen = usePlayerStore((x) => x.reportModalOpen);
28
+ const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
29
+ const reportSubject = usePlayerStore((x) => x.reportSubject);
30
+
31
+ useEffect(() => {
32
+ setReportingURL(props.reportingURL ?? null);
33
+ }, [props.reportingURL]);
34
+
26
35
  // Will call back every few seconds to send health updates
27
36
  usePlayerStatus();
28
37
 
@@ -44,40 +53,23 @@ export function Player(props: Partial<PlayerProps>) {
44
53
  };
45
54
  }, []);
46
55
 
47
- const segment = useSegment();
48
- const [lastCheck, setLastCheck] = useState(0);
49
-
50
- useEffect(() => {
51
- if (playing) {
52
- setOffline(false);
53
- return;
54
- }
55
- if (!segment) {
56
- setOffline(false);
57
- return;
58
- }
59
- const startTime = Date.parse(segment.startTime);
60
- if (!startTime) {
61
- console.error("startTime is not a number", segment.startTime);
62
- return;
63
- }
64
- const timeSinceStart = Date.now() - startTime;
65
- if (timeSinceStart > OFFLINE_THRESHOLD) {
66
- setOffline(true);
67
- return;
68
- }
69
- const handle = setTimeout(() => {
70
- setLastCheck(Date.now());
71
- }, 1000);
72
- return () => clearTimeout(handle);
73
- }, [segment, playing, lastCheck]);
74
-
75
56
  return (
76
57
  <>
77
58
  <View
78
- style={[zIndex[0], flex.values[1], w.percent[100], layout.flex.center]}
59
+ style={[
60
+ zIndex[0],
61
+ w.percent[100],
62
+ h.percent[100],
63
+ flex.shrink[1],
64
+ layout.flex.center,
65
+ ]}
79
66
  >
80
- <Fullscreen src={props.src}></Fullscreen>
67
+ <ReportModal
68
+ open={reportModalOpen}
69
+ onOpenChange={setReportModalOpen}
70
+ subject={reportSubject!}
71
+ />
72
+ <Fullscreen src={props.src}>{props.children}</Fullscreen>
81
73
  </View>
82
74
  </>
83
75
  );
@@ -8,4 +8,5 @@ export type PlayerProps = {
8
8
  setFullscreen: (isFullscreen: boolean) => void;
9
9
  ingest?: boolean;
10
10
  embedded?: boolean;
11
+ reportingURL?: string;
11
12
  };
@@ -2,5 +2,7 @@ export * from "./countdown";
2
2
  export * from "./input";
3
3
  export * from "./metrics";
4
4
  export * from "./streamer-context-menu";
5
+ export * from "./streamer-loading-overlay";
5
6
  export * from "./viewer-context-menu";
7
+ export * from "./viewer-loading-overlay";
6
8
  export * from "./viewers";
@@ -0,0 +1,195 @@
1
+ import {
2
+ ComAtprotoModerationCreateReport,
3
+ ComAtprotoModerationDefs,
4
+ } from "@atproto/api";
5
+ import { CheckCircle, Circle, Loader2 } from "lucide-react-native";
6
+ import React, { useState } from "react";
7
+ import { TouchableOpacity, View } from "react-native";
8
+ import { zero } from "../../..";
9
+ import { useSubmitReport } from "../../../livestream-store";
10
+ import {
11
+ Button,
12
+ Dialog,
13
+ DialogFooter,
14
+ ModalContent,
15
+ Text,
16
+ Textarea,
17
+ } from "../../ui";
18
+
19
+ // AT Protocol moderation reason types with proper labels
20
+ const REPORT_REASONS = [
21
+ {
22
+ value: ComAtprotoModerationDefs.REASONSPAM,
23
+ label: "Spam",
24
+ description: "Excessive unwanted promotion, replies, mentions",
25
+ },
26
+ {
27
+ value: ComAtprotoModerationDefs.REASONVIOLATION,
28
+ label: "Rule Violation",
29
+ description: "Direct, blatant violation of laws or terms of service",
30
+ },
31
+ {
32
+ value: ComAtprotoModerationDefs.REASONMISLEADING,
33
+ label: "Misleading Content",
34
+ description: "Misleading identity, affiliation, or content",
35
+ },
36
+ {
37
+ value: ComAtprotoModerationDefs.REASONSEXUAL,
38
+ label: "Sexual Content",
39
+ description: "Unwanted or mislabeled sexual content",
40
+ },
41
+ {
42
+ value: ComAtprotoModerationDefs.REASONRUDE,
43
+ label: "Harassment",
44
+ description: "Rude, harassing, explicit, or otherwise unwelcoming behavior",
45
+ },
46
+ {
47
+ value: ComAtprotoModerationDefs.REASONOTHER,
48
+ label: "Other",
49
+ description: "Reports not falling under another report category",
50
+ },
51
+ ];
52
+
53
+ interface ReportModalProps {
54
+ open: boolean;
55
+ onOpenChange: (open: boolean) => void;
56
+ onSubmit?: (reason: string, additionalComments?: string) => void;
57
+ subject: ComAtprotoModerationCreateReport.InputSchema["subject"];
58
+ title?: string;
59
+ description?: string;
60
+ }
61
+
62
+ export const ReportModal: React.FC<ReportModalProps> = ({
63
+ open,
64
+ onOpenChange,
65
+ onSubmit,
66
+ subject,
67
+ title = "Report",
68
+ description = "Why are you submitting this report?",
69
+ }) => {
70
+ const [selectedReason, setSelectedReason] = useState<string | null>(null);
71
+ const [additionalComments, setAdditionalComments] = useState<string>("");
72
+ const [isSubmitting, setIsSubmitting] = useState(false);
73
+ const [submitError, setSubmitError] = useState<string | null>(null);
74
+
75
+ const submitReport = useSubmitReport();
76
+
77
+ const handleCancel = () => {
78
+ setSelectedReason(null);
79
+ setAdditionalComments("");
80
+ setSubmitError(null);
81
+ onOpenChange(false);
82
+ };
83
+
84
+ const handleSubmit = async () => {
85
+ if (!selectedReason) return;
86
+
87
+ setIsSubmitting(true);
88
+ setSubmitError(null);
89
+
90
+ try {
91
+ submitReport(
92
+ subject,
93
+ selectedReason,
94
+ additionalComments.trim() || undefined,
95
+ );
96
+
97
+ // Reset form and close modal on success
98
+ setSelectedReason(null);
99
+ setAdditionalComments("");
100
+ onOpenChange(false);
101
+ } catch (error) {
102
+ console.error("Failed to submit report:", error);
103
+ setSubmitError("Failed to submit report. Please try again.");
104
+ } finally {
105
+ setIsSubmitting(false);
106
+ }
107
+ };
108
+
109
+ return (
110
+ <Dialog
111
+ open={open}
112
+ onOpenChange={onOpenChange}
113
+ title={title}
114
+ description={description}
115
+ showCloseButton
116
+ variant="default"
117
+ size="md"
118
+ dismissible={false}
119
+ position="center"
120
+ >
121
+ <ModalContent style={[zero.pb[2]]}>
122
+ {REPORT_REASONS.map((reason) => (
123
+ <TouchableOpacity
124
+ key={reason.value}
125
+ onPress={() => setSelectedReason(reason.value)}
126
+ style={[
127
+ zero.layout.flex.row,
128
+ zero.gap.all[2],
129
+ zero.py[3],
130
+ zero.px[3],
131
+ zero.borderRadius[8],
132
+ zero.layout.flex.alignCenter,
133
+ selectedReason === reason.value && {
134
+ backgroundColor: "rgba(0, 122, 255, 0.1)",
135
+ },
136
+ ]}
137
+ >
138
+ <View>
139
+ {selectedReason === reason.value ? <CheckCircle /> : <Circle />}
140
+ </View>
141
+ <View
142
+ style={[zero.layout.flex.column, zero.gap.all[1], zero.flex[1]]}
143
+ >
144
+ <Text style={[{ fontWeight: "600" }]}>{reason.label}</Text>
145
+ <Text style={[{ fontSize: 14, color: "rgba(255,255,255,0.7)" }]}>
146
+ {reason.description}
147
+ </Text>
148
+ </View>
149
+ </TouchableOpacity>
150
+ ))}
151
+
152
+ <View style={[zero.pb[4], zero.mt[4], zero.px[2]]}>
153
+ <Text style={[zero.mb[2]]}>Additional Comments (optional)</Text>
154
+ <Textarea
155
+ maxLength={500}
156
+ numberOfLines={3}
157
+ value={additionalComments}
158
+ onChangeText={setAdditionalComments}
159
+ placeholder="Provide additional context for this report..."
160
+ />
161
+ {submitError && (
162
+ <Text style={[zero.mt[2], { color: "red", fontSize: 14 }]}>
163
+ {submitError}
164
+ </Text>
165
+ )}
166
+ </View>
167
+ </ModalContent>
168
+ <DialogFooter>
169
+ <Button
170
+ variant="secondary"
171
+ onPress={handleCancel}
172
+ disabled={isSubmitting}
173
+ >
174
+ <Text>Cancel</Text>
175
+ </Button>
176
+ <Button
177
+ variant="primary"
178
+ onPress={handleSubmit}
179
+ disabled={!selectedReason || isSubmitting}
180
+ >
181
+ {isSubmitting ? (
182
+ <>
183
+ <Loader2 style={[{ marginRight: 8 }]} />
184
+ <Text>Submitting...</Text>
185
+ </>
186
+ ) : (
187
+ <Text>Submit</Text>
188
+ )}
189
+ </Button>
190
+ </DialogFooter>
191
+ </Dialog>
192
+ );
193
+ };
194
+
195
+ export default ReportModal;
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState } from "react";
2
+ import Animated, {
3
+ runOnJS,
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withDelay,
7
+ withTiming,
8
+ } from "react-native-reanimated";
9
+ import { pt } from "../../../lib/theme/atoms";
10
+
11
+ type LoadingOverlayProps = {
12
+ visible: boolean;
13
+ width: number;
14
+ height: number;
15
+ subtitle?: string;
16
+ messages?: string[];
17
+ interval?: number; // in milliseconds
18
+ };
19
+
20
+ const defaultMessages = [
21
+ "Creating the stream",
22
+ "Uploading thumbnails",
23
+ "Getting things ready",
24
+ "Doing some magic",
25
+ "Preparing something special",
26
+ "Reticulating splines",
27
+ "Making it nice",
28
+ "Flipping some switches",
29
+ "Adding good vibes",
30
+ "Almost there",
31
+ "Summoning your Persona",
32
+ "Awakening our true selves",
33
+ "Fusion in progress",
34
+ "Equipping the right materia",
35
+ ];
36
+
37
+ export function LoadingOverlay({
38
+ visible,
39
+ width,
40
+ height,
41
+ subtitle,
42
+ messages = defaultMessages,
43
+ interval = 3000,
44
+ }: LoadingOverlayProps) {
45
+ const [currentIndex, setCurrentIndex] = useState(0);
46
+ const [shouldRender, setShouldRender] = useState(visible);
47
+
48
+ // Animation values
49
+ const translateY = useSharedValue(0);
50
+ const opacity = useSharedValue(1);
51
+
52
+ const wholeOpacity = useSharedValue(0);
53
+
54
+ // Handle fade-in and fade-out animations
55
+ useEffect(() => {
56
+ if (visible) {
57
+ setShouldRender(true); // Ensure the component is mounted
58
+ wholeOpacity.value = withTiming(1, { duration: 500 }); // Fade in
59
+ } else {
60
+ wholeOpacity.value = withTiming(0, { duration: 500 }, () => {
61
+ // Unmount after fade-out
62
+ runOnJS(setShouldRender)(false);
63
+ });
64
+ }
65
+ }, [visible]);
66
+
67
+ // Cycle messages on a timer
68
+ useEffect(() => {
69
+ if (!visible) {
70
+ setCurrentIndex(0);
71
+ return;
72
+ }
73
+
74
+ const timeout = setTimeout(() => {
75
+ setCurrentIndex((prev) => (prev + 1) % messages.length);
76
+ }, interval);
77
+
78
+ return () => clearTimeout(timeout);
79
+ }, [visible, currentIndex, interval, messages.length]);
80
+
81
+ // Trigger animation on each message change
82
+ useEffect(() => {
83
+ if (!visible) return;
84
+
85
+ const fadeDuration = Math.min(interval / 2, 250); // Simplified fade duration
86
+
87
+ // Reset animation values
88
+ translateY.value = 20;
89
+ opacity.value = 0;
90
+
91
+ // Sequential fade-in and fade-out
92
+ translateY.value = withTiming(0, { duration: fadeDuration });
93
+ opacity.value = withTiming(1, { duration: fadeDuration }, () => {
94
+ // add a delay for interval - (fadeDuration*2)
95
+
96
+ translateY.value = withDelay(
97
+ interval - fadeDuration * 2,
98
+ withTiming(-10, { duration: fadeDuration }),
99
+ );
100
+ opacity.value = withDelay(
101
+ interval - fadeDuration * 2,
102
+ withTiming(0, { duration: fadeDuration }),
103
+ );
104
+ });
105
+ }, [currentIndex, visible]);
106
+
107
+ const animatedStyle = useAnimatedStyle(() => {
108
+ return {
109
+ transform: [{ translateY: translateY.value }],
110
+ opacity: opacity.value,
111
+ };
112
+ });
113
+
114
+ const wholeAnimatedStyle = useAnimatedStyle(() => ({
115
+ opacity: wholeOpacity.value,
116
+ }));
117
+
118
+ if (!shouldRender) return null;
119
+
120
+ return (
121
+ <Animated.View
122
+ style={[
123
+ {
124
+ position: "absolute",
125
+ top: 0,
126
+ left: 0,
127
+ width,
128
+ height,
129
+ backgroundColor: "rgba(0,0,0,0.7)",
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ zIndex: 1000,
133
+ },
134
+ wholeAnimatedStyle,
135
+ ]}
136
+ >
137
+ <Animated.Text
138
+ style={[
139
+ {
140
+ color: "white",
141
+ fontSize: 24,
142
+ fontWeight: "bold",
143
+ },
144
+ animatedStyle,
145
+ ]}
146
+ >
147
+ {messages[currentIndex]}
148
+ </Animated.Text>
149
+ <Animated.Text style={[pt[5], { color: "#a0a0a0" }]}>
150
+ {subtitle}
151
+ </Animated.Text>
152
+ </Animated.View>
153
+ );
154
+ }
@@ -1,4 +1,5 @@
1
- import { Menu } from "lucide-react-native";
1
+ import { useRootContext } from "@rn-primitives/dropdown-menu";
2
+ import { Settings } from "lucide-react-native";
2
3
  import { colors } from "../../../lib/theme";
3
4
  import { useLivestreamStore } from "../../../livestream-store";
4
5
  import { PlayerProtocol, usePlayerStore } from "../../../player-store/";
@@ -7,6 +8,7 @@ import {
7
8
  DropdownMenuCheckboxItem,
8
9
  DropdownMenuGroup,
9
10
  DropdownMenuInfo,
11
+ DropdownMenuItem,
10
12
  DropdownMenuRadioGroup,
11
13
  DropdownMenuRadioItem,
12
14
  DropdownMenuTrigger,
@@ -33,9 +35,9 @@ export function ContextMenu() {
33
35
  return (
34
36
  <DropdownMenu>
35
37
  <DropdownMenuTrigger>
36
- <Menu size={32} color={colors.gray[200]} />
38
+ <Settings color={colors.gray[200]} />
37
39
  </DropdownMenuTrigger>
38
- <ResponsiveDropdownMenuContent>
40
+ <ResponsiveDropdownMenuContent side="top" align="end">
39
41
  <DropdownMenuGroup title="Resolution">
40
42
  <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}>
41
43
  <DropdownMenuRadioItem value="source">
@@ -65,7 +67,33 @@ export function ContextMenu() {
65
67
  <Text>Show Debug Info</Text>
66
68
  </DropdownMenuCheckboxItem>
67
69
  </DropdownMenuGroup>
70
+ <DropdownMenuGroup title="Report">
71
+ <ReportButton />
72
+ </DropdownMenuGroup>
68
73
  </ResponsiveDropdownMenuContent>
69
74
  </DropdownMenu>
70
75
  );
71
76
  }
77
+
78
+ export function ReportButton() {
79
+ const livestream = useLivestreamStore((x) => x.livestream);
80
+ const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen);
81
+ const setReportSubject = usePlayerStore((x) => x.setReportSubject);
82
+ const { onOpenChange } = useRootContext();
83
+ return (
84
+ <DropdownMenuItem
85
+ onPress={() => {
86
+ if (!livestream) return;
87
+ onOpenChange?.(false);
88
+ setReportModalOpen(true);
89
+ setReportSubject({
90
+ $type: "com.atproto.repo.strongRef",
91
+ uri: livestream.uri,
92
+ cid: livestream.cid,
93
+ });
94
+ }}
95
+ >
96
+ <Text>Report Livestream...</Text>
97
+ </DropdownMenuItem>
98
+ );
99
+ }
@@ -0,0 +1,66 @@
1
+ import { Play } from "lucide-react-native";
2
+ import { useEffect } from "react";
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withTiming,
7
+ } from "react-native-reanimated";
8
+ import {
9
+ KeepAwake,
10
+ Loader,
11
+ PlayerStatus,
12
+ usePlayerStore,
13
+ useTheme,
14
+ } from "../../..";
15
+
16
+ export function ViewerLoadingOverlay() {
17
+ const status = usePlayerStore((x) => x.status);
18
+ const theme = useTheme();
19
+ const opacity = useSharedValue(0);
20
+
21
+ useEffect(() => {
22
+ if (status === PlayerStatus.PLAYING || status === PlayerStatus.SUSPEND) {
23
+ opacity.value = withTiming(0, { duration: 300 });
24
+ } else {
25
+ opacity.value = withTiming(1, { duration: 300 });
26
+ }
27
+ }, [status, opacity]);
28
+
29
+ const animatedStyle = useAnimatedStyle(() => {
30
+ return {
31
+ opacity: opacity.value,
32
+ };
33
+ });
34
+
35
+ if (status === PlayerStatus.PLAYING) {
36
+ return <KeepAwake />;
37
+ }
38
+
39
+ if (status === PlayerStatus.SUSPEND) {
40
+ return null; // No overlay when stopped
41
+ }
42
+
43
+ let spinner = <Loader size="large" />;
44
+ if (status === PlayerStatus.PAUSE) {
45
+ spinner = <Play size="$12" color={theme.styles.text.primary["color"]} />;
46
+ }
47
+
48
+ return (
49
+ <Animated.View
50
+ style={[
51
+ {
52
+ position: "absolute",
53
+ width: "100%",
54
+ height: "100%",
55
+ zIndex: 998,
56
+ alignItems: "center",
57
+ justifyContent: "center",
58
+ backgroundColor: "rgba(0,0,0,0.3)",
59
+ },
60
+ animatedStyle,
61
+ ]}
62
+ >
63
+ {spinner}
64
+ </Animated.View>
65
+ );
66
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { usePlayerStore, useStreamKey } from "../..";
2
+ import { PlayerStatus, usePlayerStore, useStreamKey } from "../..";
3
3
  import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives";
4
4
 
5
5
  export default function useWebRTC(
@@ -7,6 +7,7 @@ export default function useWebRTC(
7
7
  ): [MediaStream | null, boolean] {
8
8
  const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
9
9
  const [stuck, setStuck] = useState<boolean>(false);
10
+ const setStatus = usePlayerStore((x) => x.setStatus);
10
11
 
11
12
  const lastChange = useRef<number>(0);
12
13
 
@@ -29,7 +30,12 @@ export default function useWebRTC(
29
30
  });
30
31
  peerConnection.addEventListener("connectionstatechange", () => {
31
32
  console.log("connection state change", peerConnection.connectionState);
32
- if (peerConnection.connectionState === "closed") {
33
+ if (
34
+ peerConnection.connectionState === "closed" ||
35
+ peerConnection.connectionState === "failed" ||
36
+ peerConnection.connectionState === "disconnected"
37
+ ) {
38
+ console.log("setting stuck to true", peerConnection.connectionState);
33
39
  setStuck(true);
34
40
  }
35
41
  if (peerConnection.connectionState !== "connected") {
@@ -52,6 +58,7 @@ export default function useWebRTC(
52
58
  if (lastAudioFramesReceived !== audioFramesReceived) {
53
59
  lastAudioFramesReceived = audioFramesReceived;
54
60
  lastChange.current = Date.now();
61
+ setStatus(PlayerStatus.PLAYING);
55
62
  setStuck(false);
56
63
  }
57
64
  }
@@ -60,6 +67,7 @@ export default function useWebRTC(
60
67
  if (lastFramesReceived !== framesReceived) {
61
68
  lastFramesReceived = framesReceived;
62
69
  lastChange.current = Date.now();
70
+ setStatus(PlayerStatus.PLAYING);
63
71
  setStuck(false);
64
72
  }
65
73
  }