Live video on the AT Protocol
1import { SessionManager } from "@atproto/api/dist/session-manager";
2import { useContext } from "react";
3import { PlaceStreamChatProfile, PlaceStreamLivestream } from "streamplace";
4import { createStore, StoreApi, useStore } from "zustand";
5import storage from "../storage";
6import { StreamplaceContext } from "../streamplace-provider/context";
7
8export interface ContentMetadataResult {
9 record: any;
10 uri: string;
11 cid: string;
12 rkey?: string;
13}
14
15// there are three categories of XRPC that we need to handle:
16// 1. Public (probably) OAuth XRPC to the users' PDS for apps that use this API.
17// 2. Confidental OAuth to the Streamplace server for doing things that require
18// server-side authentication. This isn't very much stuff yet, but you need
19// to log into Streamplace to do things like have Streamplace update your
20// activity status.
21// 3. Anonymous XRPC to the Streamplace server for stuff like `getLiveUsers`. This
22// is easy to handle internal to this library.
23// For the Streamplace app itself, all three are the same. For apps that aren't
24// doing OAuth through the Streamplace node, we need to expose an interface that
25// allows them to use atcute or whatever for 1.
26
27export interface StreamplaceState {
28 url: string;
29 liveUsers: PlaceStreamLivestream.LivestreamView[] | null;
30 setLiveUsers: (opts: {
31 liveUsers?: PlaceStreamLivestream.LivestreamView[];
32 liveUsersLoading?: boolean;
33 liveUsersError?: string | null;
34 liveUsersRefresh?: number;
35 }) => void;
36 liveUsersRefresh: number;
37 liveUsersLoading: boolean;
38 liveUsersError: string | null;
39 oauthSession: SessionManager | null | undefined;
40 handle: string | null;
41 chatProfile: PlaceStreamChatProfile.Record | null;
42
43 // Content metadata state
44 contentMetadata: ContentMetadataResult | null;
45 setContentMetadata: (metadata: ContentMetadataResult | null) => void;
46
47 broadcasterDID: string | null;
48 setBroadcasterDID: (broadcasterDID: string | null) => void;
49 serverDID: string | null;
50 setServerDID: (serverDID: string | null) => void;
51
52 // Volume state
53 volume: number;
54 muted: boolean;
55 setVolume: (volume: number) => void;
56 setMuted: (muted: boolean) => void;
57
58 // Danmu settings
59 danmuUnlocked: boolean;
60 danmuEnabled: boolean;
61 danmuOpacity: number;
62 danmuSpeed: number;
63 danmuLaneCount: number;
64 danmuMaxMessages: number;
65 setDanmuUnlocked: (unlocked: boolean) => void;
66 setDanmuEnabled: (enabled: boolean) => void;
67 setDanmuOpacity: (opacity: number) => void;
68 setDanmuSpeed: (speed: number) => void;
69 setDanmuLaneCount: (laneCount: number) => void;
70 setDanmuMaxMessages: (maxMessages: number) => void;
71}
72
73export type StreamplaceStore = StoreApi<StreamplaceState>;
74
75export const makeStreamplaceStore = ({
76 url,
77}: {
78 url: string;
79}): StoreApi<StreamplaceState> => {
80 const VOLUME_STORAGE_KEY = "globalVolume";
81 const MUTED_STORAGE_KEY = "globalMuted";
82 const DANMU_UNLOCKED_KEY = "danmuUnlocked";
83 const DANMU_ENABLED_KEY = "danmuEnabled";
84 const DANMU_OPACITY_KEY = "danmuOpacity";
85 const DANMU_SPEED_KEY = "danmuSpeed";
86 const DANMU_LANE_COUNT_KEY = "danmuLaneCount";
87 const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages";
88
89 const store = createStore<StreamplaceState>()((set) => ({
90 url,
91 liveUsers: null,
92 setLiveUsers: (opts: {
93 liveUsers?: PlaceStreamLivestream.LivestreamView[];
94 liveUsersLoading?: boolean;
95 liveUsersError?: string | null;
96 liveUsersRefresh?: number;
97 }) => {
98 set({
99 ...opts,
100 });
101 },
102 liveUsersRefresh: 0,
103 liveUsersLoading: true,
104 liveUsersError: null,
105 oauthSession: null,
106 handle: null,
107 chatProfile: null,
108
109 broadcasterDID: null,
110 setBroadcasterDID: (broadcasterDID: string | null) =>
111 set({ broadcasterDID }),
112 serverDID: null,
113 setServerDID: (serverDID: string | null) => set({ serverDID }),
114
115 // Content metadata
116 contentMetadata: null,
117 setContentMetadata: (metadata) => set({ contentMetadata: metadata }),
118
119 // Volume state - start with defaults
120 volume: 1.0,
121 muted: false,
122
123 setVolume: (volume: number) => {
124 // Ensure the value is finite and within bounds
125 if (!Number.isFinite(volume)) {
126 console.warn("Invalid volume value:", volume, "- using 1.0");
127 volume = 1.0;
128 }
129 const clampedVolume = Math.max(0, Math.min(1, volume));
130
131 set({ volume: clampedVolume });
132
133 // Auto-unmute if volume > 0
134 if (clampedVolume > 0) {
135 set({ muted: false });
136 storage.setItem(MUTED_STORAGE_KEY, "false").catch(console.error);
137 }
138
139 storage
140 .setItem(VOLUME_STORAGE_KEY, clampedVolume.toString())
141 .catch(console.error);
142 },
143
144 setMuted: (muted: boolean) => {
145 set({ muted });
146 storage.setItem(MUTED_STORAGE_KEY, muted.toString()).catch(console.error);
147 },
148
149 // Danmu settings - start with defaults
150 danmuUnlocked: false,
151 danmuEnabled: false,
152 danmuOpacity: 80,
153 danmuSpeed: 1,
154 danmuLaneCount: 12,
155 danmuMaxMessages: 50,
156
157 setDanmuUnlocked: (unlocked: boolean) => {
158 set({ danmuUnlocked: unlocked });
159 storage
160 .setItem(DANMU_UNLOCKED_KEY, unlocked.toString())
161 .catch(console.error);
162 },
163
164 setDanmuEnabled: (enabled: boolean) => {
165 set({ danmuEnabled: enabled });
166 storage
167 .setItem(DANMU_ENABLED_KEY, enabled.toString())
168 .catch(console.error);
169 },
170
171 setDanmuOpacity: (opacity: number) => {
172 const clamped = Math.max(0, Math.min(100, opacity));
173 set({ danmuOpacity: clamped });
174 storage
175 .setItem(DANMU_OPACITY_KEY, clamped.toString())
176 .catch(console.error);
177 },
178
179 setDanmuSpeed: (speed: number) => {
180 const clamped = Math.max(0.1, Math.min(3, speed));
181 set({ danmuSpeed: clamped });
182 storage.setItem(DANMU_SPEED_KEY, clamped.toString()).catch(console.error);
183 },
184
185 setDanmuLaneCount: (laneCount: number) => {
186 const clamped = Math.max(4, Math.min(20, laneCount));
187 set({ danmuLaneCount: clamped });
188 storage
189 .setItem(DANMU_LANE_COUNT_KEY, clamped.toString())
190 .catch(console.error);
191 },
192
193 setDanmuMaxMessages: (maxMessages: number) => {
194 const clamped = Math.max(5, Math.min(200, maxMessages));
195 set({ danmuMaxMessages: clamped });
196 storage
197 .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString())
198 .catch(console.error);
199 },
200 }));
201
202 // Load initial volume and danmu state from storage asynchronously
203 (async () => {
204 try {
205 const storedVolume = await storage.getItem(VOLUME_STORAGE_KEY);
206 const storedMuted = await storage.getItem(MUTED_STORAGE_KEY);
207 const storedDanmuUnlocked = await storage.getItem(DANMU_UNLOCKED_KEY);
208 const storedDanmuEnabled = await storage.getItem(DANMU_ENABLED_KEY);
209 const storedDanmuOpacity = await storage.getItem(DANMU_OPACITY_KEY);
210 const storedDanmuSpeed = await storage.getItem(DANMU_SPEED_KEY);
211 const storedDanmuLaneCount = await storage.getItem(DANMU_LANE_COUNT_KEY);
212 const storedDanmuMaxMessages = await storage.getItem(
213 DANMU_MAX_MESSAGES_KEY,
214 );
215
216 let initialVolume = 1.0;
217 let initialMuted = false;
218 let initialDanmuUnlocked = false;
219 let initialDanmuEnabled = false;
220 let initialDanmuOpacity = 80;
221 let initialDanmuSpeed = 1;
222 let initialDanmuLaneCount = 12;
223 let initialDanmuMaxMessages = 50;
224
225 if (storedVolume) {
226 const parsedVolume = parseFloat(storedVolume);
227 if (
228 Number.isFinite(parsedVolume) &&
229 parsedVolume >= 0 &&
230 parsedVolume <= 1
231 ) {
232 initialVolume = parsedVolume;
233 }
234 }
235
236 if (storedMuted) {
237 initialMuted = storedMuted === "true";
238 }
239
240 if (storedDanmuUnlocked) {
241 initialDanmuUnlocked = storedDanmuUnlocked === "true";
242 }
243
244 if (storedDanmuEnabled) {
245 initialDanmuEnabled = storedDanmuEnabled === "true";
246 }
247
248 if (storedDanmuOpacity) {
249 const parsed = parseInt(storedDanmuOpacity);
250 if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) {
251 initialDanmuOpacity = parsed;
252 }
253 }
254
255 if (storedDanmuSpeed) {
256 const parsed = parseFloat(storedDanmuSpeed);
257 if (Number.isFinite(parsed) && parsed >= 0.1 && parsed <= 3) {
258 initialDanmuSpeed = parsed;
259 }
260 }
261
262 if (storedDanmuLaneCount) {
263 const parsed = parseInt(storedDanmuLaneCount);
264 if (Number.isFinite(parsed) && parsed >= 4 && parsed <= 20) {
265 initialDanmuLaneCount = parsed;
266 }
267 }
268
269 if (storedDanmuMaxMessages) {
270 const parsed = parseInt(storedDanmuMaxMessages);
271 if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 200) {
272 initialDanmuMaxMessages = parsed;
273 }
274 }
275
276 store.setState({
277 volume: initialVolume,
278 muted: initialMuted,
279 danmuUnlocked: initialDanmuUnlocked,
280 danmuEnabled: initialDanmuEnabled,
281 danmuOpacity: initialDanmuOpacity,
282 danmuSpeed: initialDanmuSpeed,
283 danmuLaneCount: initialDanmuLaneCount,
284 danmuMaxMessages: initialDanmuMaxMessages,
285 });
286 } catch (error) {
287 console.error("Failed to load state from storage:", error);
288 }
289 })();
290
291 return store;
292};
293
294export function getStreamplaceStoreFromContext(): StreamplaceStore {
295 const context = useContext(StreamplaceContext);
296 if (!context) {
297 throw new Error(
298 "useStreamplaceStore must be used within a StreamplaceProvider",
299 );
300 }
301 return context.store;
302}
303
304export function useStreamplaceStore<U>(
305 selector: (state: StreamplaceState) => U,
306): U {
307 return useStore(getStreamplaceStoreFromContext(), selector);
308}
309
310export const useUrl = () => useStreamplaceStore((x) => x.url);
311
312export const useDID = () => useStreamplaceStore((x) => x.oauthSession?.did);
313
314export const useHandle = () => useStreamplaceStore((x) => x.handle);
315export const useSetHandle = (): ((handle: string) => void) => {
316 const store = getStreamplaceStoreFromContext();
317 return (handle: string) => store.setState({ handle });
318};
319
320// Content metadata hooks
321export const useContentMetadata = () =>
322 useStreamplaceStore((x) => x.contentMetadata);
323
324export const useSetContentMetadata = () => {
325 const store = getStreamplaceStoreFromContext();
326 return (metadata: ContentMetadataResult | null) =>
327 store.setState({ contentMetadata: metadata });
328};
329
330// Volume/muted hooks
331export const useVolume = () => useStreamplaceStore((x) => x.volume);
332export const useMuted = () => useStreamplaceStore((x) => x.muted);
333export const useSetVolume = () => useStreamplaceStore((x) => x.setVolume);
334export const useSetMuted = () => useStreamplaceStore((x) => x.setMuted);
335
336// Composite hook for effective volume (0 if muted) - used by video components
337export const useEffectiveVolume = () =>
338 useStreamplaceStore((state) => {
339 const effectiveVolume = state.muted ? 0 : state.volume;
340 // Ensure we always return a finite number for HTMLMediaElement.volume
341 return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0;
342 });
343
344// Danmu convenience hooks
345export const useDanmuUnlocked = () =>
346 useStreamplaceStore((x) => x.danmuUnlocked);
347export const useDanmuEnabled = () => useStreamplaceStore((x) => x.danmuEnabled);
348export const useDanmuOpacity = () => useStreamplaceStore((x) => x.danmuOpacity);
349export const useDanmuSpeed = () => useStreamplaceStore((x) => x.danmuSpeed);
350export const useDanmuLaneCount = () =>
351 useStreamplaceStore((x) => x.danmuLaneCount);
352export const useDanmuMaxMessages = () =>
353 useStreamplaceStore((x) => x.danmuMaxMessages);
354export const useSetDanmuUnlocked = () =>
355 useStreamplaceStore((x) => x.setDanmuUnlocked);
356export const useSetDanmuEnabled = () =>
357 useStreamplaceStore((x) => x.setDanmuEnabled);
358export const useSetDanmuOpacity = () =>
359 useStreamplaceStore((x) => x.setDanmuOpacity);
360export const useSetDanmuSpeed = () =>
361 useStreamplaceStore((x) => x.setDanmuSpeed);
362export const useSetDanmuLaneCount = () =>
363 useStreamplaceStore((x) => x.setDanmuLaneCount);
364export const useSetDanmuMaxMessages = () =>
365 useStreamplaceStore((x) => x.setDanmuMaxMessages);
366
367// Composite hook that calls all individual hooks
368export const useDanmuSettings = () => {
369 const danmuUnlocked = useDanmuUnlocked();
370 const danmuEnabled = useDanmuEnabled();
371 const danmuOpacity = useDanmuOpacity();
372 const danmuSpeed = useDanmuSpeed();
373 const danmuLaneCount = useDanmuLaneCount();
374 const danmuMaxMessages = useDanmuMaxMessages();
375 const setDanmuUnlocked = useSetDanmuUnlocked();
376 const setDanmuEnabled = useSetDanmuEnabled();
377 const setDanmuOpacity = useSetDanmuOpacity();
378 const setDanmuSpeed = useSetDanmuSpeed();
379 const setDanmuLaneCount = useSetDanmuLaneCount();
380 const setDanmuMaxMessages = useSetDanmuMaxMessages();
381
382 return {
383 danmuUnlocked,
384 danmuEnabled,
385 danmuOpacity,
386 danmuSpeed,
387 danmuLaneCount,
388 danmuMaxMessages,
389 setDanmuUnlocked,
390 setDanmuEnabled,
391 setDanmuOpacity,
392 setDanmuSpeed,
393 setDanmuLaneCount,
394 setDanmuMaxMessages,
395 };
396};
397
398export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";