@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
@@ -0,0 +1,89 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Platform } from "react-native";
3
+
4
+ export interface PointerDevice {
5
+ hasHover: boolean;
6
+ hasFinePointer: boolean;
7
+ isMouseDriven: boolean;
8
+ isTouchDriven: boolean;
9
+ }
10
+
11
+ /**
12
+ * Hook to detect if the device is primarily mouse-driven vs touch-driven
13
+ * Uses CSS media queries to detect hover and pointer capabilities
14
+ */
15
+ export function usePointerDevice(): PointerDevice {
16
+ const [pointerDevice, setPointerDevice] = useState<PointerDevice>(() => {
17
+ // Default values for non-web platforms
18
+ if (Platform.OS !== "web") {
19
+ return {
20
+ hasHover: false,
21
+ hasFinePointer: false,
22
+ isMouseDriven: false,
23
+ isTouchDriven: true,
24
+ };
25
+ }
26
+
27
+ // Initial web detection
28
+ if (typeof window !== "undefined" && window.matchMedia) {
29
+ const hasHover = window.matchMedia("(hover: hover)").matches;
30
+ const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
31
+
32
+ return {
33
+ hasHover,
34
+ hasFinePointer,
35
+ isMouseDriven: hasHover && hasFinePointer,
36
+ isTouchDriven: !hasHover || !hasFinePointer,
37
+ };
38
+ }
39
+
40
+ // Fallback for SSR or environments without matchMedia
41
+ return {
42
+ hasHover: false,
43
+ hasFinePointer: false,
44
+ isMouseDriven: false,
45
+ isTouchDriven: true,
46
+ };
47
+ });
48
+
49
+ useEffect(() => {
50
+ // Only run on web platforms
51
+ if (
52
+ Platform.OS !== "web" ||
53
+ typeof window === "undefined" ||
54
+ !window.matchMedia
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ const hoverQuery = window.matchMedia("(hover: hover)");
60
+ const pointerQuery = window.matchMedia("(pointer: fine)");
61
+
62
+ const updatePointerDevice = () => {
63
+ const hasHover = hoverQuery.matches;
64
+ const hasFinePointer = pointerQuery.matches;
65
+
66
+ setPointerDevice({
67
+ hasHover,
68
+ hasFinePointer,
69
+ isMouseDriven: hasHover && hasFinePointer,
70
+ isTouchDriven: !hasHover || !hasFinePointer,
71
+ });
72
+ };
73
+
74
+ // Set up listeners for media query changes
75
+ hoverQuery.addEventListener("change", updatePointerDevice);
76
+ pointerQuery.addEventListener("change", updatePointerDevice);
77
+
78
+ // Initial update
79
+ updatePointerDevice();
80
+
81
+ // Cleanup
82
+ return () => {
83
+ hoverQuery.removeEventListener("change", updatePointerDevice);
84
+ pointerQuery.removeEventListener("change", updatePointerDevice);
85
+ };
86
+ }, []);
87
+
88
+ return pointerDevice;
89
+ }
package/src/index.tsx CHANGED
@@ -18,10 +18,19 @@ export * as ui from "./components/ui";
18
18
 
19
19
  export * from "./components/ui";
20
20
 
21
- export * as theme from "./lib/theme";
22
- export * as atoms from "./lib/theme/atoms";
21
+ export * as zero from "./ui";
23
22
 
24
23
  export * from "./hooks";
25
24
 
25
+ // Theme system exports
26
+ export * from "./lib/theme";
27
+
26
28
  export * from "./components/chat/chat";
27
29
  export * from "./components/chat/chat-box";
30
+ export * from "./components/chat/system-message";
31
+ export { default as VideoRetry } from "./components/mobile-player/video-retry";
32
+ export * from "./lib/system-messages";
33
+
34
+ export * from "./components/share/sharesheet";
35
+
36
+ export * from "./components/keep-awake";
@@ -0,0 +1,135 @@
1
+ import { ChatMessageViewHydrated } from "streamplace";
2
+
3
+ export enum SystemMessageType {
4
+ stream_start = "stream_start",
5
+ stream_end = "stream_end",
6
+ notification = "notification",
7
+ }
8
+
9
+ export interface SystemMessageMetadata {
10
+ username?: string;
11
+ action?: string;
12
+ count?: number;
13
+ duration?: string;
14
+ reason?: string;
15
+ streamerName?: string;
16
+ }
17
+
18
+ /**
19
+ * Creates a system message with the proper structure
20
+ * @param type The type of system message
21
+ * @param text The message text
22
+ * @param metadata Optional metadata for the message
23
+ * @returns A properly formatted ChatMessageViewHydrated object
24
+ */
25
+ export const createSystemMessage = (
26
+ type: SystemMessageType,
27
+ text: string,
28
+ metadata?: SystemMessageMetadata,
29
+ date: Date = new Date(),
30
+ ): ChatMessageViewHydrated => {
31
+ const now = date;
32
+
33
+ return {
34
+ uri: `at://did:sys:system/place.stream.chat.message/${now.getTime()}`,
35
+ cid: `system-${now.getTime()}`,
36
+ author: {
37
+ did: "did:sys:system",
38
+ handle: type, // Use handle to specify the type of system message
39
+ },
40
+ record: {
41
+ text,
42
+ createdAt: now.toISOString(),
43
+ streamer: "system",
44
+ $type: "place.stream.chat.message",
45
+ },
46
+ indexedAt: now.toISOString(),
47
+ chatProfile: {
48
+ color: { red: 128, green: 128, blue: 128 }, // Gray color for system messages
49
+ },
50
+ };
51
+ };
52
+
53
+ /**
54
+ * System message factory functions for common scenarios
55
+ */
56
+ export const SystemMessages = {
57
+ streamStart: (streamerName: string): ChatMessageViewHydrated =>
58
+ createSystemMessage(
59
+ SystemMessageType.stream_start,
60
+ `Now streaming - ${streamerName}`,
61
+ {
62
+ streamerName,
63
+ },
64
+ ),
65
+
66
+ // technically, streams can't 'end' on Streamplace
67
+ // possibly we could use deleting or editing streams (`endedAt` param) for this?
68
+ streamEnd: (duration?: string): ChatMessageViewHydrated =>
69
+ createSystemMessage(
70
+ SystemMessageType.stream_end,
71
+ duration ? `Stream has ended. Duration: ${duration}` : "Stream has ended",
72
+ { duration },
73
+ ),
74
+
75
+ notification: (message: string): ChatMessageViewHydrated =>
76
+ createSystemMessage(SystemMessageType.notification, message),
77
+ };
78
+
79
+ /**
80
+ * Checks if a message is a system message
81
+ * @param message The message to check
82
+ * @returns True if the message is a system message
83
+ */
84
+ export const isSystemMessage = (message: ChatMessageViewHydrated): boolean => {
85
+ return message.author.did === "did:sys:system";
86
+ };
87
+
88
+ /**
89
+ * Gets the system message type from a message
90
+ * @param message The message to check
91
+ * @returns The system message type or null if not a system message
92
+ */
93
+ export const getSystemMessageType = (
94
+ message: ChatMessageViewHydrated,
95
+ ): SystemMessageType | null => {
96
+ if (!isSystemMessage(message)) {
97
+ return null;
98
+ }
99
+ return message.author.handle as SystemMessageType;
100
+ };
101
+
102
+ /**
103
+ * Parses metadata from a system message based on its type
104
+ * @param message The system message to parse
105
+ * @returns The parsed metadata
106
+ */
107
+ export const parseSystemMessageMetadata = (
108
+ message: ChatMessageViewHydrated,
109
+ ): SystemMessageMetadata => {
110
+ const metadata: SystemMessageMetadata = {};
111
+ const type = getSystemMessageType(message);
112
+ const text = message.record.text;
113
+
114
+ if (!type) return metadata;
115
+
116
+ switch (type) {
117
+ case "stream_end": {
118
+ const durationMatch = text.match(/Duration:\s*(\d+:\d+(?::\d+)?)/);
119
+ if (durationMatch) {
120
+ metadata.duration = durationMatch[1];
121
+ }
122
+ break;
123
+ }
124
+
125
+ case "stream_start": {
126
+ const streamerMatch = text.match(/^(.+?)\s+is now live!/);
127
+ if (streamerMatch) {
128
+ metadata.streamerName = streamerMatch[1];
129
+ }
130
+ break;
131
+ }
132
+ }
133
+
134
+ return metadata;
135
+ };
@@ -1,4 +1,4 @@
1
- import { RichText } from "@atproto/api";
1
+ import { ComAtprotoModerationCreateReport, RichText } from "@atproto/api";
2
2
  import { useCallback } from "react";
3
3
  import {
4
4
  ChatMessageViewHydrated,
@@ -23,6 +23,27 @@ export const useSetReplyToMessage = () => {
23
23
  );
24
24
  };
25
25
 
26
+ export const usePendingHides = () =>
27
+ useLivestreamStore((state) => state.pendingHides);
28
+
29
+ export const useAddPendingHide = () => {
30
+ const store = getStoreFromContext();
31
+ return useCallback(
32
+ (messageUri: string) => {
33
+ const state = store.getState();
34
+ if (!state.pendingHides.includes(messageUri)) {
35
+ const newPendingHides = [...state.pendingHides, messageUri];
36
+ const newState = reduceChat(state, [], [], [messageUri]);
37
+ store.setState({
38
+ ...newState,
39
+ pendingHides: newPendingHides,
40
+ });
41
+ }
42
+ },
43
+ [store],
44
+ );
45
+ };
46
+
26
47
  export type NewChatMessage = {
27
48
  text: string;
28
49
  reply?: {
@@ -87,7 +108,7 @@ export const useCreateChatMessage = () => {
87
108
  chatProfile: chatProfile || undefined,
88
109
  };
89
110
 
90
- state = reduceChat(state, [localChat], []);
111
+ state = reduceChat(state, [localChat], [], []);
91
112
  store.setState(state);
92
113
 
93
114
  await pdsAgent.com.atproto.repo.createRecord({
@@ -109,7 +130,9 @@ const buildSortedChatList = (
109
130
  const bTime = parseInt(b.split("-")[0], 10);
110
131
  return bTime - aTime;
111
132
  });
112
- return sortedKeys.map((key) => chatIndex[key]);
133
+ return sortedKeys
134
+ .map((key) => chatIndex[key])
135
+ .filter((msg) => !removedKeys.has(msg.uri));
113
136
  };
114
137
 
115
138
  const profileIsDifferent = (
@@ -138,8 +161,13 @@ export const reduceChatIncremental = (
138
161
  state: LivestreamState,
139
162
  newMessages: ChatMessageViewHydrated[],
140
163
  blocks: PlaceStreamDefs.BlockView[],
164
+ hideUris: string[] = [],
141
165
  ): LivestreamState => {
142
- if (newMessages.length === 0 && blocks.length === 0) {
166
+ if (
167
+ newMessages.length === 0 &&
168
+ blocks.length === 0 &&
169
+ hideUris.length === 0
170
+ ) {
143
171
  return state;
144
172
  }
145
173
 
@@ -148,6 +176,17 @@ export const reduceChatIncremental = (
148
176
  let hasChanges = false;
149
177
  const removedKeys = new Set<string>();
150
178
 
179
+ console.log("newMessages", newMessages);
180
+
181
+ for (const msg of newMessages) {
182
+ if (msg.deleted) {
183
+ hasChanges = true;
184
+ console.log("deleted", msg.uri);
185
+ removedKeys.add(msg.uri);
186
+ }
187
+ }
188
+ newMessages = newMessages.filter((msg) => msg.deleted !== true);
189
+
151
190
  // handle blocks
152
191
  if (blocks.length > 0) {
153
192
  const blockedDIDs = new Set(blocks.map((block) => block.record.subject));
@@ -160,17 +199,32 @@ export const reduceChatIncremental = (
160
199
  }
161
200
  }
162
201
 
202
+ if (hideUris.length > 0) {
203
+ for (const [key, message] of Object.entries(newChatIndex)) {
204
+ if (hideUris.includes(message.uri)) {
205
+ delete newChatIndex[key];
206
+ removedKeys.add(key);
207
+ hasChanges = true;
208
+ }
209
+ }
210
+ }
211
+
163
212
  const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = [];
164
213
 
165
214
  for (const message of newMessages) {
215
+ // don't worry about messages that will be hidden
216
+ if (state.pendingHides.includes(message.uri)) {
217
+ continue;
218
+ }
219
+
166
220
  const date = new Date(message.record.createdAt);
167
221
  const key = `${date.getTime()}-${message.uri}`;
168
222
 
169
223
  // only change the ref if the profile is different to avoid re-renders elsewhere
170
224
  if (
171
- profileIsDifferent(message.chatProfile, newAuthors[message.author.handle])
225
+ profileIsDifferent(message.chatProfile, newAuthors[message.author.did])
172
226
  ) {
173
- newAuthors[message.author.handle] = message.chatProfile;
227
+ newAuthors[message.author.did] = message.chatProfile;
174
228
  }
175
229
 
176
230
  // skip messages we already have
@@ -214,17 +268,20 @@ export const reduceChatIncremental = (
214
268
 
215
269
  if (parentMsgKey) {
216
270
  const parentMsg = newChatIndex[parentMsgKey];
217
- processedMessage = {
218
- ...message,
219
- replyTo: {
220
- cid: parentMsg.cid,
221
- uri: parentMsg.uri,
222
- author: parentMsg.author,
223
- record: parentMsg.record,
224
- chatProfile: parentMsg.chatProfile,
225
- indexedAt: parentMsg.indexedAt,
226
- },
227
- };
271
+ // Don't allow replies to system messages
272
+ if (parentMsg.author.did !== "did:sys:system") {
273
+ processedMessage = {
274
+ ...message,
275
+ replyTo: {
276
+ cid: parentMsg.cid,
277
+ uri: parentMsg.uri,
278
+ author: parentMsg.author,
279
+ record: parentMsg.record,
280
+ chatProfile: parentMsg.chatProfile,
281
+ indexedAt: parentMsg.indexedAt,
282
+ },
283
+ };
284
+ }
228
285
  }
229
286
  }
230
287
  }
@@ -251,11 +308,82 @@ export const reduceChatIncremental = (
251
308
  removedKeys,
252
309
  );
253
310
 
311
+ // Clean up pendingHides - remove URIs that we've now processed
312
+ let newPendingHides = state.pendingHides;
313
+ if (hideUris.length > 0) {
314
+ newPendingHides = state.pendingHides.filter(
315
+ (uri) => !hideUris.includes(uri),
316
+ );
317
+ }
318
+
254
319
  return {
255
320
  ...state,
321
+ authors: newAuthors,
256
322
  chatIndex: newChatIndex,
257
323
  chat: newChatList,
324
+ pendingHides: newPendingHides,
258
325
  };
259
326
  };
260
327
 
328
+ export const useSubmitReport = () => {
329
+ const pdsAgent = usePDSAgent();
330
+ const userDID = useDID();
331
+
332
+ return useCallback(
333
+ async (
334
+ subject: ComAtprotoModerationCreateReport.InputSchema["subject"],
335
+ reasonType: string,
336
+ reason?: string,
337
+ // no clue about this
338
+ moderationSvcDid: string = "did:web:stream.place",
339
+ ) => {
340
+ if (!pdsAgent || !userDID) {
341
+ throw new Error("No PDS agent or user DID found");
342
+ }
343
+
344
+ try {
345
+ const response = await pdsAgent.com.atproto.moderation.createReport(
346
+ {
347
+ reasonType,
348
+ reason,
349
+ subject: subject,
350
+ },
351
+ {
352
+ headers: {
353
+ // "atproto-proxy": `${userDID}#atproto_labeler`,
354
+ },
355
+ },
356
+ );
357
+
358
+ return response;
359
+ } catch (error) {
360
+ console.error("Failed to submit report:", error);
361
+ throw error;
362
+ }
363
+ },
364
+ [pdsAgent, userDID],
365
+ );
366
+ };
367
+
368
+ export const useReportChatMessage = () => {
369
+ const submitReport = useSubmitReport();
370
+
371
+ return useCallback(
372
+ async (
373
+ message: ChatMessageViewHydrated,
374
+ reasonType: string,
375
+ reason?: string,
376
+ ) => {
377
+ const reportSubject = {
378
+ $type: "com.atproto.repo.strongRef",
379
+ uri: message.uri,
380
+ cid: message.cid,
381
+ };
382
+
383
+ return await submitReport(reportSubject, reasonType, reason);
384
+ },
385
+ [submitReport],
386
+ );
387
+ };
388
+
261
389
  export const reduceChat = reduceChatIncremental;
@@ -13,9 +13,19 @@ export interface LivestreamState {
13
13
  authors: { [key: string]: ChatMessageViewHydrated["chatProfile"] };
14
14
  livestream: LivestreamViewHydrated | null;
15
15
  viewers: number | null;
16
+ pendingHides: string[];
16
17
  segment: PlaceStreamSegment.Record | null;
18
+ recentSegments: PlaceStreamSegment.Record[];
19
+ problems: LivestreamProblem[];
17
20
  renditions: PlaceStreamDefs.Rendition[];
18
21
  replyToMessage: ChatMessageViewHydrated | null;
19
22
  streamKey: string | null;
20
23
  setStreamKey: (key: string | null) => void;
21
24
  }
25
+
26
+ export interface LivestreamProblem {
27
+ code: string;
28
+ message: string;
29
+ severity: "error" | "warning" | "info";
30
+ link?: string;
31
+ }
@@ -13,12 +13,15 @@ export const makeLivestreamStore = (): StoreApi<LivestreamState> => {
13
13
  chat: [],
14
14
  livestream: null,
15
15
  viewers: null,
16
+ pendingHides: [],
16
17
  segment: null,
17
18
  renditions: [],
18
19
  replyToMessage: null,
19
20
  streamKey: null,
20
21
  setStreamKey: (sk) => set({ streamKey: sk }),
21
22
  authors: {},
23
+ recentSegments: [],
24
+ problems: [],
22
25
  }));
23
26
  };
24
27
 
@@ -0,0 +1,96 @@
1
+ import { PlaceStreamSegment } from "streamplace";
2
+ import { LivestreamProblem } from "./livestream-state";
3
+
4
+ const VARIANCE_THRESHOLD = 0.5;
5
+ const DURATION_THRESHOLD = 5000000000; // 5s in ns
6
+
7
+ const detectVariableSegmentLength = (
8
+ segments: PlaceStreamSegment.Record[],
9
+ ): { variable: boolean; duration: boolean } => {
10
+ if (segments.length < 3) {
11
+ // Need at least 3 segments to detect variability
12
+ return { variable: false, duration: false };
13
+ }
14
+
15
+ const durations = segments
16
+ .map((segment) => segment.duration)
17
+ .filter(
18
+ (duration): duration is number => duration !== undefined && duration > 0,
19
+ );
20
+
21
+ if (durations.length < 3) {
22
+ return { variable: false, duration: false };
23
+ }
24
+
25
+ // Calculate mean
26
+ const mean =
27
+ durations.reduce((sum: number, duration: number) => sum + duration, 0) /
28
+ durations.length;
29
+
30
+ // Calculate standard deviation
31
+ const variance =
32
+ durations.reduce((sum: number, duration: number) => {
33
+ const diff = duration - mean;
34
+ return sum + diff * diff;
35
+ }, 0) / durations.length;
36
+ const stdDev = Math.sqrt(variance);
37
+
38
+ // Calculate coefficient of variation (CV)
39
+ const cv = stdDev / mean;
40
+
41
+ // CV > 0.5 indicates high variability
42
+ // This threshold can be adjusted based on testing
43
+ return {
44
+ variable: cv > VARIANCE_THRESHOLD,
45
+ duration: mean > DURATION_THRESHOLD,
46
+ };
47
+ };
48
+
49
+ export const findProblems = (
50
+ segments: PlaceStreamSegment.Record[],
51
+ ): LivestreamProblem[] => {
52
+ const problems: LivestreamProblem[] = [];
53
+ let hasBFrames = false;
54
+ for (const segment of segments) {
55
+ const video = segment.video?.[0];
56
+ if (!video) {
57
+ // i mean yes this is a problem but it can't happen yet
58
+ continue;
59
+ }
60
+ if (video.bframes === true) {
61
+ hasBFrames = true;
62
+ break;
63
+ }
64
+ }
65
+ if (hasBFrames) {
66
+ problems.push({
67
+ code: "bframes",
68
+ message:
69
+ "Your stream contains B-Frames, which are not supported in Streamplace. Your stream will stutter.",
70
+ severity: "error",
71
+ link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
72
+ });
73
+ }
74
+
75
+ const { variable, duration } = detectVariableSegmentLength(segments);
76
+ if (variable) {
77
+ problems.push({
78
+ code: "variable_segment_length",
79
+ message:
80
+ "Your stream contains variable segment lengths, which may cause playback issues.",
81
+ severity: "warning",
82
+ link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
83
+ });
84
+ }
85
+ if (duration) {
86
+ problems.push({
87
+ code: "long_segments",
88
+ message:
89
+ "Your stream contains long segments (>5s). This will work fine, but increases the delay of the livestream.",
90
+ severity: "warning",
91
+ link: "https://stream.place/docs/guides/start-streaming/obs/#obs-configuration",
92
+ });
93
+ }
94
+
95
+ return problems;
96
+ };
@@ -3,13 +3,18 @@ import {
3
3
  ChatMessageViewHydrated,
4
4
  LivestreamViewHydrated,
5
5
  PlaceStreamChatDefs,
6
+ PlaceStreamChatGate,
6
7
  PlaceStreamChatMessage,
7
8
  PlaceStreamDefs,
8
9
  PlaceStreamLivestream,
9
10
  PlaceStreamSegment,
10
11
  } from "streamplace";
12
+ import { SystemMessages } from "../lib/system-messages";
11
13
  import { reduceChat } from "./chat";
12
14
  import { LivestreamState } from "./livestream-state";
15
+ import { findProblems } from "./problems";
16
+
17
+ const MAX_RECENT_SEGMENTS = 10;
13
18
 
14
19
  export const handleWebSocketMessages = (
15
20
  state: LivestreamState,
@@ -17,9 +22,23 @@ export const handleWebSocketMessages = (
17
22
  ): LivestreamState => {
18
23
  for (const message of messages) {
19
24
  if (PlaceStreamLivestream.isLivestreamView(message)) {
25
+ const newLivestream = message as LivestreamViewHydrated;
26
+ const oldLivestream = state.livestream;
27
+
28
+ // check if this is actually new
29
+ if (!oldLivestream || oldLivestream.uri !== newLivestream.uri) {
30
+ const streamTitle = newLivestream.record.title || "something cool!";
31
+ const systemMessage = SystemMessages.streamStart(streamTitle);
32
+ // set proper times
33
+ systemMessage.indexedAt = newLivestream.indexedAt;
34
+ systemMessage.record.createdAt = newLivestream.record.createdAt;
35
+
36
+ state = reduceChat(state, [systemMessage], []);
37
+ }
38
+
20
39
  state = {
21
40
  ...state,
22
- livestream: message as LivestreamViewHydrated,
41
+ livestream: newLivestream,
23
42
  };
24
43
  } else if (PlaceStreamLivestream.isViewerCount(message)) {
25
44
  state = {
@@ -36,16 +55,24 @@ export const handleWebSocketMessages = (
36
55
  indexedAt: message.indexedAt,
37
56
  chatProfile: (message as any).chatProfile,
38
57
  replyTo: (message as any).replyTo,
58
+ deleted: message.deleted,
39
59
  };
40
- state = reduceChat(state, [hydrated], []);
60
+ state = reduceChat(state, [hydrated], [], []);
41
61
  } else if (PlaceStreamSegment.isRecord(message)) {
62
+ const newRecentSegments = [...state.recentSegments];
63
+ newRecentSegments.unshift(message);
64
+ if (newRecentSegments.length > MAX_RECENT_SEGMENTS) {
65
+ newRecentSegments.pop();
66
+ }
42
67
  state = {
43
68
  ...state,
44
69
  segment: message as PlaceStreamSegment.Record,
70
+ recentSegments: newRecentSegments,
71
+ problems: findProblems(newRecentSegments),
45
72
  };
46
73
  } else if (PlaceStreamDefs.isBlockView(message)) {
47
74
  const block = message as PlaceStreamDefs.BlockView;
48
- state = reduceChat(state, [], [block]);
75
+ state = reduceChat(state, [], [block], []);
49
76
  } else if (PlaceStreamDefs.isRenditions(message)) {
50
77
  state = {
51
78
  ...state,
@@ -56,7 +83,20 @@ export const handleWebSocketMessages = (
56
83
  ...state,
57
84
  profile: message,
58
85
  };
86
+ } else if (PlaceStreamChatGate.isRecord(message)) {
87
+ const hideRecord = message as PlaceStreamChatGate.Record;
88
+ const hiddenMessageUri = hideRecord.hiddenMessage;
89
+ const newPendingHides = [...state.pendingHides];
90
+ if (!newPendingHides.includes(hiddenMessageUri)) {
91
+ newPendingHides.push(hiddenMessageUri);
92
+ }
93
+
94
+ state = {
95
+ ...state,
96
+ pendingHides: newPendingHides,
97
+ };
98
+ state = reduceChat(state, [], [], [hiddenMessageUri]);
59
99
  }
60
100
  }
61
- return reduceChat(state, [], []);
101
+ return reduceChat(state, [], [], []);
62
102
  };