Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
14 kB · 434 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435import { SessionManager } from "@atproto/api/dist/session-manager";import { useContext } from "react";import { place } from "streamplace";import { createStore, StoreApi, useStore } from "zustand";import storage from "../storage";import { StreamplaceContext } from "../streamplace-provider/context";import { BrandingAsset } from "./branding";
export interface ContentMetadataResult { record: any; uri: string; cid: string; rkey?: string;}
// there are three categories of XRPC that we need to handle:// 1. Public (probably) OAuth XRPC to the users' PDS for apps that use this API.// 2. Confidental OAuth to the Streamplace server for doing things that require// server-side authentication. This isn't very much stuff yet, but you need// to log into Streamplace to do things like have Streamplace update your// activity status.// 3. Anonymous XRPC to the Streamplace server for stuff like `getLiveUsers`. This// is easy to handle internal to this library.// For the Streamplace app itself, all three are the same. For apps that aren't// doing OAuth through the Streamplace node, we need to expose an interface that// allows them to use atcute or whatever for 1.
export interface StreamplaceState { url: string; liveUsers: place.stream.livestream.LivestreamView[] | null; setLiveUsers: (opts: { liveUsers?: place.stream.livestream.LivestreamView[]; liveUsersLoading?: boolean; liveUsersError?: string | null; liveUsersRefresh?: number; }) => void; liveUsersRefresh: number; liveUsersLoading: boolean; liveUsersError: string | null; oauthSession: SessionManager | null | undefined; // Injected by the app: prompt the user to log in (opens the login modal). // Lets components (comments, likes, chat) gate actions behind auth. onNeedsLogin: (() => void) | null; handle: string | null; chatProfile: place.stream.chat.profile.Main | null;
ingests: place.stream.ingest.defs.Ingest[] | null; setIngests: (ingests: place.stream.ingest.defs.Ingest[] | null) => void;
// Content metadata state contentMetadata: ContentMetadataResult | null; setContentMetadata: (metadata: ContentMetadataResult | null) => void;
broadcasterDID: string | null; setBroadcasterDID: (broadcasterDID: string | null) => void; serverDID: string | null; setServerDID: (serverDID: string | null) => void; adminDIDs: string[]; setAdminDIDs: (adminDIDs: string[]) => void;
playbackWorkerUrl: string | null; setPlaybackWorkerUrl: (url: string | null) => void;
gamesEnabled: boolean; setGamesEnabled: (enabled: boolean) => void;
// Branding state branding: Record<string, BrandingAsset> | null; brandingLoading: boolean; brandingError: string | null;
// Volume state volume: number; muted: boolean; setVolume: (volume: number) => void; setMuted: (muted: boolean) => void;
// Danmu settings danmuUnlocked: boolean; danmuEnabled: boolean; danmuOpacity: number; danmuSpeed: number; danmuLaneCount: number; danmuMaxMessages: number; setDanmuUnlocked: (unlocked: boolean) => void; setDanmuEnabled: (enabled: boolean) => void; setDanmuOpacity: (opacity: number) => void; setDanmuSpeed: (speed: number) => void; setDanmuLaneCount: (laneCount: number) => void; setDanmuMaxMessages: (maxMessages: number) => void;}
export type StreamplaceStore = StoreApi<StreamplaceState>;
export const makeStreamplaceStore = ({ url,}: { url: string;}): StoreApi<StreamplaceState> => { const VOLUME_STORAGE_KEY = "globalVolume"; const MUTED_STORAGE_KEY = "globalMuted"; const DANMU_UNLOCKED_KEY = "danmuUnlocked"; const DANMU_ENABLED_KEY = "danmuEnabled"; const DANMU_OPACITY_KEY = "danmuOpacity"; const DANMU_SPEED_KEY = "danmuSpeed"; const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages";
const store = createStore<StreamplaceState>()((set) => ({ url, liveUsers: null, setLiveUsers: (opts: { liveUsers?: place.stream.livestream.LivestreamView[]; liveUsersLoading?: boolean; liveUsersError?: string | null; liveUsersRefresh?: number; }) => { set({ ...opts, }); }, liveUsersRefresh: 0, liveUsersLoading: true, liveUsersError: null, oauthSession: null, onNeedsLogin: null, handle: null, chatProfile: null, ingests: null, setIngests: (ingests: place.stream.ingest.defs.Ingest[] | null) => set({ ingests: ingests }), broadcasterDID: null, setBroadcasterDID: (broadcasterDID: string | null) => set({ broadcasterDID }), serverDID: null, setServerDID: (serverDID: string | null) => set({ serverDID }), adminDIDs: [], setAdminDIDs: (adminDIDs: string[]) => set({ adminDIDs }),
playbackWorkerUrl: null, setPlaybackWorkerUrl: (playbackWorkerUrl: string | null) => set({ playbackWorkerUrl }),
gamesEnabled: false, setGamesEnabled: (gamesEnabled: boolean) => set({ gamesEnabled }),
// Content metadata contentMetadata: null, setContentMetadata: (metadata) => set({ contentMetadata: metadata }),
// Branding state branding: null, brandingLoading: false, brandingError: null,
// Volume state - start with defaults volume: 1.0, muted: false,
setVolume: (volume: number) => { // Ensure the value is finite and within bounds if (!Number.isFinite(volume)) { console.warn("Invalid volume value:", volume, "- using 1.0"); volume = 1.0; } const clampedVolume = Math.max(0, Math.min(1, volume));
set({ volume: clampedVolume });
// Auto-unmute if volume > 0 if (clampedVolume > 0) { set({ muted: false }); storage.setItem(MUTED_STORAGE_KEY, "false").catch(console.error); }
storage .setItem(VOLUME_STORAGE_KEY, clampedVolume.toString()) .catch(console.error); },
setMuted: (muted: boolean) => { set({ muted }); storage.setItem(MUTED_STORAGE_KEY, muted.toString()).catch(console.error); },
// Danmu settings - start with defaults danmuUnlocked: false, danmuEnabled: false, danmuOpacity: 80, danmuSpeed: 1, danmuLaneCount: 12, danmuMaxMessages: 50,
setDanmuUnlocked: (unlocked: boolean) => { set({ danmuUnlocked: unlocked }); storage .setItem(DANMU_UNLOCKED_KEY, unlocked.toString()) .catch(console.error); },
setDanmuEnabled: (enabled: boolean) => { set({ danmuEnabled: enabled }); storage .setItem(DANMU_ENABLED_KEY, enabled.toString()) .catch(console.error); },
setDanmuOpacity: (opacity: number) => { const clamped = Math.max(0, Math.min(100, opacity)); set({ danmuOpacity: clamped }); storage .setItem(DANMU_OPACITY_KEY, clamped.toString()) .catch(console.error); },
setDanmuSpeed: (speed: number) => { const clamped = Math.max(0.1, Math.min(3, speed)); set({ danmuSpeed: clamped }); storage.setItem(DANMU_SPEED_KEY, clamped.toString()).catch(console.error); },
setDanmuLaneCount: (laneCount: number) => { const clamped = Math.max(4, Math.min(20, laneCount)); set({ danmuLaneCount: clamped }); storage .setItem(DANMU_LANE_COUNT_KEY, clamped.toString()) .catch(console.error); },
setDanmuMaxMessages: (maxMessages: number) => { const clamped = Math.max(5, Math.min(200, maxMessages)); set({ danmuMaxMessages: clamped }); storage .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) .catch(console.error); }, }));
// Load initial volume and danmu state from storage asynchronously (async () => { try { const storedVolume = await storage.getItem(VOLUME_STORAGE_KEY); const storedMuted = await storage.getItem(MUTED_STORAGE_KEY); const storedDanmuUnlocked = await storage.getItem(DANMU_UNLOCKED_KEY); const storedDanmuEnabled = await storage.getItem(DANMU_ENABLED_KEY); const storedDanmuOpacity = await storage.getItem(DANMU_OPACITY_KEY); const storedDanmuSpeed = await storage.getItem(DANMU_SPEED_KEY); const storedDanmuLaneCount = await storage.getItem(DANMU_LANE_COUNT_KEY); const storedDanmuMaxMessages = await storage.getItem( DANMU_MAX_MESSAGES_KEY, );
let initialVolume = 1.0; let initialMuted = false; let initialDanmuUnlocked = false; let initialDanmuEnabled = false; let initialDanmuOpacity = 80; let initialDanmuSpeed = 1; let initialDanmuLaneCount = 12; let initialDanmuMaxMessages = 50;
if (storedVolume) { const parsedVolume = parseFloat(storedVolume); if ( Number.isFinite(parsedVolume) && parsedVolume >= 0 && parsedVolume <= 1 ) { initialVolume = parsedVolume; } }
if (storedMuted) { initialMuted = storedMuted === "true"; }
if (storedDanmuUnlocked) { initialDanmuUnlocked = storedDanmuUnlocked === "true"; }
if (storedDanmuEnabled) { initialDanmuEnabled = storedDanmuEnabled === "true"; }
if (storedDanmuOpacity) { const parsed = parseInt(storedDanmuOpacity); if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) { initialDanmuOpacity = parsed; } }
if (storedDanmuSpeed) { const parsed = parseFloat(storedDanmuSpeed); if (Number.isFinite(parsed) && parsed >= 0.1 && parsed <= 3) { initialDanmuSpeed = parsed; } }
if (storedDanmuLaneCount) { const parsed = parseInt(storedDanmuLaneCount); if (Number.isFinite(parsed) && parsed >= 4 && parsed <= 20) { initialDanmuLaneCount = parsed; } }
if (storedDanmuMaxMessages) { const parsed = parseInt(storedDanmuMaxMessages); if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 200) { initialDanmuMaxMessages = parsed; } }
store.setState({ volume: initialVolume, muted: initialMuted, danmuUnlocked: initialDanmuUnlocked, danmuEnabled: initialDanmuEnabled, danmuOpacity: initialDanmuOpacity, danmuSpeed: initialDanmuSpeed, danmuLaneCount: initialDanmuLaneCount, danmuMaxMessages: initialDanmuMaxMessages, }); } catch (error) { console.error("Failed to load state from storage:", error); } })();
return store;};
export function getStreamplaceStoreFromContext(): StreamplaceStore { const context = useContext(StreamplaceContext); if (!context) { throw new Error( "useStreamplaceStore must be used within a StreamplaceProvider", ); } return context.store;}
export function useStreamplaceStore<U>( selector: (state: StreamplaceState) => U,): U { return useStore(getStreamplaceStoreFromContext(), selector);}
export const useUrl = () => useStreamplaceStore((x) => x.url);
export const useDID = () => useStreamplaceStore((x) => x.oauthSession?.did);export const useOnNeedsLogin = () => useStreamplaceStore((x) => x.onNeedsLogin);
export const useHandle = () => useStreamplaceStore((x) => x.handle);export const useSetHandle = (): ((handle: string) => void) => { const store = getStreamplaceStoreFromContext(); return (handle: string) => store.setState({ handle });};
// Content metadata hooksexport const useContentMetadata = () => useStreamplaceStore((x) => x.contentMetadata);
export const useSetContentMetadata = () => { const store = getStreamplaceStoreFromContext(); return (metadata: ContentMetadataResult | null) => store.setState({ contentMetadata: metadata });};
// Volume/muted hooksexport const useVolume = () => useStreamplaceStore((x) => x.volume);export const useMuted = () => useStreamplaceStore((x) => x.muted);export const useSetVolume = () => useStreamplaceStore((x) => x.setVolume);export const useSetMuted = () => useStreamplaceStore((x) => x.setMuted);
// Composite hook for effective volume (0 if muted) - used by video componentsexport const useEffectiveVolume = () => useStreamplaceStore((state) => { const effectiveVolume = state.muted ? 0 : state.volume; // Ensure we always return a finite number for HTMLMediaElement.volume return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0; });
// Danmu convenience hooksexport const useDanmuUnlocked = () => useStreamplaceStore((x) => x.danmuUnlocked);export const useDanmuEnabled = () => useStreamplaceStore((x) => x.danmuEnabled);export const useDanmuOpacity = () => useStreamplaceStore((x) => x.danmuOpacity);export const useDanmuSpeed = () => useStreamplaceStore((x) => x.danmuSpeed);export const useDanmuLaneCount = () => useStreamplaceStore((x) => x.danmuLaneCount);export const useDanmuMaxMessages = () => useStreamplaceStore((x) => x.danmuMaxMessages);export const useSetDanmuUnlocked = () => useStreamplaceStore((x) => x.setDanmuUnlocked);export const useSetDanmuEnabled = () => useStreamplaceStore((x) => x.setDanmuEnabled);export const useSetDanmuOpacity = () => useStreamplaceStore((x) => x.setDanmuOpacity);export const useSetDanmuSpeed = () => useStreamplaceStore((x) => x.setDanmuSpeed);export const useSetDanmuLaneCount = () => useStreamplaceStore((x) => x.setDanmuLaneCount);export const useSetDanmuMaxMessages = () => useStreamplaceStore((x) => x.setDanmuMaxMessages);
// Composite hook that calls all individual hooksexport const useDanmuSettings = () => { const danmuUnlocked = useDanmuUnlocked(); const danmuEnabled = useDanmuEnabled(); const danmuOpacity = useDanmuOpacity(); const danmuSpeed = useDanmuSpeed(); const danmuLaneCount = useDanmuLaneCount(); const danmuMaxMessages = useDanmuMaxMessages(); const setDanmuUnlocked = useSetDanmuUnlocked(); const setDanmuEnabled = useSetDanmuEnabled(); const setDanmuOpacity = useSetDanmuOpacity(); const setDanmuSpeed = useSetDanmuSpeed(); const setDanmuLaneCount = useSetDanmuLaneCount(); const setDanmuMaxMessages = useSetDanmuMaxMessages();
return { danmuUnlocked, danmuEnabled, danmuOpacity, danmuSpeed, danmuLaneCount, danmuMaxMessages, setDanmuUnlocked, setDanmuEnabled, setDanmuOpacity, setDanmuSpeed, setDanmuLaneCount, setDanmuMaxMessages, };};