@streamplace/components 0.7.3 → 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 (85) 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 -1
  14. package/dist/components/mobile-player/ui/report-modal.js +90 -0
  15. package/dist/components/mobile-player/ui/{loading.js → streamer-loading-overlay.js} +1 -1
  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/slider.js +5 -0
  28. package/dist/hooks/index.js +1 -0
  29. package/dist/hooks/usePointerDevice.js +71 -0
  30. package/dist/index.js +10 -3
  31. package/dist/lib/system-messages.js +101 -0
  32. package/dist/livestream-store/chat.js +111 -18
  33. package/dist/livestream-store/livestream-store.js +3 -0
  34. package/dist/livestream-store/problems.js +76 -0
  35. package/dist/livestream-store/websocket-consumer.js +39 -4
  36. package/dist/player-store/player-store.js +30 -4
  37. package/dist/streamplace-store/block.js +51 -12
  38. package/dist/ui/index.js +79 -0
  39. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  40. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/56540125 +0 -0
  41. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/67b1eb60 +0 -0
  42. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/7c275f90 +0 -0
  43. package/package.json +6 -2
  44. package/src/components/chat/chat-box.tsx +295 -25
  45. package/src/components/chat/chat-message.tsx +6 -7
  46. package/src/components/chat/chat.tsx +192 -41
  47. package/src/components/chat/emoji-suggestions.tsx +94 -0
  48. package/src/components/chat/mod-view.tsx +119 -40
  49. package/src/components/chat/system-message.tsx +38 -0
  50. package/src/components/icons/bluesky-icon.tsx +9 -0
  51. package/src/components/keep-awake.native.tsx +13 -0
  52. package/src/components/keep-awake.tsx +3 -0
  53. package/src/components/mobile-player/fullscreen.native.tsx +12 -3
  54. package/src/components/mobile-player/fullscreen.tsx +10 -3
  55. package/src/components/mobile-player/player.tsx +28 -36
  56. package/src/components/mobile-player/props.tsx +1 -0
  57. package/src/components/mobile-player/ui/index.ts +2 -1
  58. package/src/components/mobile-player/ui/report-modal.tsx +195 -0
  59. package/src/components/mobile-player/ui/{loading.tsx → streamer-loading-overlay.tsx} +1 -1
  60. package/src/components/mobile-player/ui/viewer-context-menu.tsx +31 -3
  61. package/src/components/mobile-player/ui/viewer-loading-overlay.tsx +66 -0
  62. package/src/components/mobile-player/use-webrtc.tsx +10 -2
  63. package/src/components/mobile-player/video-retry.tsx +28 -0
  64. package/src/components/mobile-player/video.native.tsx +24 -10
  65. package/src/components/mobile-player/video.tsx +100 -21
  66. package/src/components/share/sharesheet.tsx +185 -0
  67. package/src/components/ui/dialog.tsx +1 -1
  68. package/src/components/ui/dropdown.tsx +13 -13
  69. package/src/components/ui/index.ts +2 -0
  70. package/src/components/ui/primitives/modal.tsx +0 -1
  71. package/src/components/ui/slider.tsx +1 -0
  72. package/src/hooks/index.ts +1 -0
  73. package/src/hooks/usePointerDevice.ts +89 -0
  74. package/src/index.tsx +11 -2
  75. package/src/lib/system-messages.ts +135 -0
  76. package/src/livestream-store/chat.tsx +145 -17
  77. package/src/livestream-store/livestream-state.tsx +10 -0
  78. package/src/livestream-store/livestream-store.tsx +3 -0
  79. package/src/livestream-store/problems.tsx +96 -0
  80. package/src/livestream-store/websocket-consumer.tsx +44 -4
  81. package/src/player-store/player-state.tsx +21 -4
  82. package/src/player-store/player-store.tsx +38 -5
  83. package/src/streamplace-store/block.tsx +55 -13
  84. package/src/ui/index.ts +86 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  };
@@ -1,7 +1,8 @@
1
1
  export * from "./countdown";
2
2
  export * from "./input";
3
- export * from "./loading";
4
3
  export * from "./metrics";
5
4
  export * from "./streamer-context-menu";
5
+ export * from "./streamer-loading-overlay";
6
6
  export * from "./viewer-context-menu";
7
+ export * from "./viewer-loading-overlay";
7
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;
@@ -18,7 +18,7 @@ type LoadingOverlayProps = {
18
18
  };
19
19
 
20
20
  const defaultMessages = [
21
- "Creating your stream",
21
+ "Creating the stream",
22
22
  "Uploading thumbnails",
23
23
  "Getting things ready",
24
24
  "Doing some magic",
@@ -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
  }
@@ -0,0 +1,28 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { PlayerStatus, usePlayerStore } from "../..";
3
+
4
+ export default function VideoRetry(props: { children: React.ReactNode }) {
5
+ const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
6
+ const [retries, setRetries] = useState(0);
7
+ const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING);
8
+
9
+ useEffect(() => {
10
+ if (!playing) {
11
+ const jitter = 500 + Math.random() * 1500;
12
+ retryTimeoutRef.current = setTimeout(() => {
13
+ console.log("Retrying video playback...");
14
+ setRetries((prevRetries) => prevRetries + 1);
15
+ }, jitter);
16
+ }
17
+
18
+ return () => {
19
+ if (retryTimeoutRef.current) {
20
+ console.log("Clearing retry timeout");
21
+ clearTimeout(retryTimeoutRef.current);
22
+ retryTimeoutRef.current = null;
23
+ }
24
+ };
25
+ }, [!playing]);
26
+
27
+ return <React.Fragment key={retries}>{props.children}</React.Fragment>;
28
+ }
@@ -89,6 +89,14 @@ export function NativeVideo() {
89
89
  }, [setStatus]);
90
90
 
91
91
  const player = useVideoPlayer(url, (player) => {
92
+ player.addListener("playingChange", (newIsPlaying) => {
93
+ console.log("playingChange", newIsPlaying);
94
+ if (newIsPlaying) {
95
+ setStatus(PlayerStatus.PLAYING);
96
+ } else {
97
+ setStatus(PlayerStatus.WAITING);
98
+ }
99
+ });
92
100
  player.loop = true;
93
101
  player.muted = muted;
94
102
  player.play();
@@ -115,12 +123,14 @@ export function NativeVideo() {
115
123
  ).map((evType) => {
116
124
  return player.addListener(evType, (...args) => {
117
125
  const now = new Date();
126
+ console.log("video native event", evType);
118
127
  playerEvent(spurl, now.toISOString(), evType, { args: args });
119
128
  });
120
129
  });
121
130
 
122
131
  subs.push(
123
132
  player.addListener("playingChange", (newIsPlaying) => {
133
+ console.log("playingChange", newIsPlaying);
124
134
  if (newIsPlaying) {
125
135
  setStatus(PlayerStatus.PLAYING);
126
136
  } else {
@@ -164,6 +174,7 @@ export function NativeWHEP() {
164
174
  PlayerProtocol.WEBRTC,
165
175
  );
166
176
  const [stream, stuck] = useWebRTC(url);
177
+ const status = usePlayerStore((x) => x.status);
167
178
 
168
179
  const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth);
169
180
  const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight);
@@ -189,22 +200,25 @@ export function NativeWHEP() {
189
200
  const volume = usePlayerStore((x) => x.volume);
190
201
 
191
202
  useEffect(() => {
192
- if (stuck) {
203
+ if (stuck && status === PlayerStatus.PLAYING) {
204
+ console.log("setting status to stalled", status);
193
205
  setStatus(PlayerStatus.STALLED);
194
- } else {
206
+ }
207
+ if (!stuck && status === PlayerStatus.STALLED) {
208
+ console.log("setting status to playing", status);
195
209
  setStatus(PlayerStatus.PLAYING);
196
210
  }
197
- }, [stuck, setStatus]);
211
+ }, [stuck, status]);
198
212
 
199
213
  const mediaStream = stream as unknown as MediaStream;
200
214
 
201
- useEffect(() => {
202
- if (!mediaStream) {
203
- setStatus(PlayerStatus.WAITING);
204
- return;
205
- }
206
- setStatus(PlayerStatus.PLAYING);
207
- }, [mediaStream, setStatus]);
215
+ // useEffect(() => {
216
+ // if (!mediaStream) {
217
+ // setStatus(PlayerStatus.WAITING);
218
+ // return;
219
+ // }
220
+ // setStatus(PlayerStatus.PLAYING);
221
+ // }, [mediaStream, setStatus]);
208
222
 
209
223
  useEffect(() => {
210
224
  if (!mediaStream) {