Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

at eli/segmentation-fixes 398 lines 13 kB view raw
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";