Live video on the AT Protocol
79
fork

Configure Feed

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

at eli/localhost-dev-is-back 283 lines 8.8 kB view raw
1import { ComAtprotoModerationCreateReport } from "@atproto/api"; 2import { useContext, useEffect, useState } from "react"; 3import { ChatMessageViewHydrated } from "streamplace"; 4import { createStore, StoreApi, useStore } from "zustand"; 5import { useLivestreamStore } from "../livestream-store"; 6import { useStreamplaceStore } from "../streamplace-store"; 7import { PlayerContext } from "./context"; 8import { 9 IngestMediaSource, 10 PlayerEvent, 11 PlayerProtocol, 12 PlayerState, 13 PlayerStatus, 14} from "./player-state"; 15 16export type PlayerStore = StoreApi<PlayerState>; 17 18export const makePlayerStore = (id?: string): StoreApi<PlayerState> => { 19 return createStore<PlayerState>()((set) => ({ 20 id: id || Math.random().toString(36).slice(8), 21 selectedRendition: "source", 22 setSelectedRendition: (rendition: string) => 23 set((state) => ({ ...state, selectedRendition: rendition })), 24 protocol: PlayerProtocol.WEBRTC, 25 setProtocol: (protocol: PlayerProtocol) => 26 set((state) => ({ ...state, protocol: protocol })), 27 28 src: "", 29 setSrc: (src: string) => set(() => ({ src })), 30 31 ingestStarting: false, 32 setIngestStarting: (ingestStarting: boolean) => 33 set(() => ({ ingestStarting })), 34 35 ingestMediaSource: undefined, 36 setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) => 37 set(() => ({ ingestMediaSource })), 38 39 ingestCamera: "user", 40 setIngestCamera: (ingestCamera: "user" | "environment") => 41 set(() => ({ ingestCamera })), 42 43 ingestConnectionState: null, 44 setIngestConnectionState: ( 45 ingestConnectionState: RTCPeerConnectionState | null, 46 ) => set(() => ({ ingestConnectionState })), 47 48 ingestAutoStart: false, 49 setIngestAutoStart: (ingestAutoStart: boolean) => 50 set(() => ({ ingestAutoStart })), 51 52 ingestStarted: null, 53 setIngestStarted: (timestamp: number | null) => 54 set(() => ({ ingestStarted: timestamp })), 55 56 fullscreen: false, 57 setFullscreen: (isFullscreen: boolean) => 58 set(() => ({ fullscreen: isFullscreen })), 59 60 status: PlayerStatus.START, 61 setStatus: (status: PlayerStatus) => set(() => ({ status })), 62 63 playTime: 0, 64 setPlayTime: (playTime: number) => set(() => ({ playTime })), 65 66 videoRef: undefined, 67 setVideoRef: ( 68 videoRef: 69 | React.MutableRefObject<HTMLVideoElement | null> 70 | ((instance: HTMLVideoElement | null) => void) 71 | null 72 | undefined, 73 ) => set(() => ({ videoRef })), 74 75 pipMode: false, 76 setPipMode: (pipMode: boolean) => set(() => ({ pipMode })), 77 78 // Picture-in-Picture action function (set by player component) 79 pipAction: undefined, 80 setPipAction: (action: (() => void) | undefined) => 81 set(() => ({ pipAction: action })), 82 83 // Player element width/height setters for global sync 84 playerWidth: undefined, 85 setPlayerWidth: (playerWidth: number) => set(() => ({ playerWidth })), 86 playerHeight: undefined, 87 setPlayerHeight: (playerHeight: number) => set(() => ({ playerHeight })), 88 89 // * Whether mute was forced by the browser or not for autoplay 90 // * Will get set to 'false' if the user has interacted with the volume 91 muteWasForced: false, 92 setMuteWasForced: (muteWasForced: boolean) => 93 set(() => ({ muteWasForced })), 94 95 autoplayFailed: false, 96 setAutoplayFailed: (autoplayFailed: boolean) => 97 set(() => ({ autoplayFailed })), 98 99 embedded: false, 100 setEmbedded: (embedded: boolean) => set(() => ({ embedded })), 101 102 showControls: true, 103 controlsTimeout: undefined, 104 setShowControls: (showControls: boolean) => 105 set({ showControls, controlsTimeout: undefined }), 106 107 telemetry: true, 108 setTelemetry: (telemetry: boolean) => set(() => ({ telemetry })), 109 110 ingestLive: false, 111 setIngestLive: (ingestLive: boolean) => set(() => ({ ingestLive })), 112 113 reportingURL: null, 114 setReportingURL: (reportingURL: string | null) => 115 set(() => ({ reportingURL })), 116 117 playerEvent: async ( 118 url: string, 119 time: string, 120 eventType: string, 121 meta: { [key: string]: any }, 122 ) => 123 set((x) => { 124 const data: PlayerEvent = { 125 time: time, 126 playerId: x.id, 127 eventType: eventType, 128 meta: { 129 ...meta, 130 }, 131 }; 132 try { 133 // fetch url from sp provider 134 const reportingURL = x.reportingURL ?? `${url}/api/player-event`; 135 fetch(reportingURL, { 136 method: "POST", 137 body: JSON.stringify(data), 138 }); 139 } catch (e) { 140 console.error("error sending player telemetry", e); 141 } 142 return {}; 143 }), 144 145 // Clear the controls timeout, if it exists. 146 // Should be called on player unmount. 147 clearControlsTimeout: () => 148 set((state) => { 149 if (state.controlsTimeout) { 150 clearTimeout(state.controlsTimeout); 151 } 152 return { controlsTimeout: undefined }; 153 }), 154 155 setUserInteraction: () => 156 set((p) => { 157 // controls timeout 158 if (p.controlsTimeout) { 159 clearTimeout(p.controlsTimeout); 160 } 161 let controlsTimeout = setTimeout(() => p.setShowControls(false), 1000); 162 return { showControls: true, controlsTimeout }; 163 }), 164 165 showDebugInfo: false, 166 setShowDebugInfo: (showDebugInfo: boolean) => 167 set(() => ({ showDebugInfo })), 168 169 modMessage: null, 170 setModMessage: (modMessage: ChatMessageViewHydrated | null) => 171 set(() => ({ modMessage })), 172 173 reportModalOpen: false, 174 setReportModalOpen: (reportModalOpen: boolean) => 175 set(() => ({ reportModalOpen })), 176 177 reportSubject: null, 178 setReportSubject: ( 179 subject: ComAtprotoModerationCreateReport.InputSchema["subject"] | null, 180 ) => set(() => ({ reportSubject: subject })), 181 })); 182}; 183 184export function usePlayerContext() { 185 const context = useContext(PlayerContext); 186 if (!context) { 187 throw new Error("usePlayerContext must be used within a PlayerProvider"); 188 } 189 return context; 190} 191 192// Get a specific player store by ID 193export function getPlayerStoreById(id: string): PlayerStore { 194 const { players } = usePlayerContext(); 195 const playerStore = players[id]; 196 if (!playerStore) { 197 throw new Error(`No player found with ID: ${id}`); 198 } 199 return playerStore; 200} 201 202// Will get the first player ID in the context 203export function getFirstPlayerID(): string { 204 const { players } = usePlayerContext(); 205 const playerIds = Object.keys(players); 206 if (playerIds.length === 0) { 207 throw new Error("No players found in context"); 208 } 209 return playerIds[0]; 210} 211 212export function getPlayerStoreFromContext(): PlayerStore { 213 console.warn( 214 "getPlayerStoreFromContext is deprecated. Use getPlayerStoreById instead.", 215 ); 216 const { players } = usePlayerContext(); 217 const playerIds = Object.keys(players); 218 if (playerIds.length === 0) { 219 throw new Error("No players found in context"); 220 } 221 return players[playerIds[0]]; 222} 223 224// Use a specific player store by ID 225// If no ID is provided, it will use the first player in the context 226export function usePlayerStore<U>( 227 selector: (state: PlayerState) => U, 228 playerId?: string, 229): U { 230 if (!playerId) { 231 playerId = Object.keys(usePlayerContext().players)[0]; 232 } 233 const store = getPlayerStoreById(playerId); 234 return useStore(store, selector); 235} 236 237/* Convenience selectors/hooks */ 238export const usePlayerProtocol = ( 239 playerId?: string, 240): [PlayerProtocol, (protocol: PlayerProtocol) => void] => 241 usePlayerStore((x) => [x.protocol, x.setProtocol], playerId); 242 243export const intoPlayerProtocol = (protocol: string): PlayerProtocol => { 244 switch (protocol) { 245 case "hls": 246 return PlayerProtocol.HLS; 247 case "progressive-mp4": 248 return PlayerProtocol.PROGRESSIVE_MP4; 249 case "progressive-webm": 250 return PlayerProtocol.PROGRESSIVE_WEBM; 251 default: 252 return PlayerProtocol.WEBRTC; 253 } 254}; 255 256// returns true if the livestream has been offline for more than 10 seconds and we're not playing 257export const useOffline = () => { 258 const status = usePlayerStore((x) => x.status); 259 const segment = useLivestreamStore((x) => x.segment); 260 const [now, setNow] = useState(Date.now()); 261 useEffect(() => { 262 const interval = setInterval(() => { 263 setNow(Date.now()); 264 }, 500); 265 return () => clearInterval(interval); 266 }, []); 267 if (status === PlayerStatus.PLAYING) { 268 return false; 269 } 270 if (!segment?.startTime) { 271 return false; 272 } 273 return now - Date.parse(segment.startTime) > 10000; 274}; 275 276export const useIsMyStream = () => { 277 const myHandle = useStreamplaceStore((state) => state.handle); 278 const myDid = useStreamplaceStore((state) => state.oauthSession?.did); 279 const channelId = usePlayerStore((state) => state.src); 280 return () => { 281 return myHandle === channelId || myDid === channelId; 282 }; 283};