Live video on the AT Protocol
79
fork

Configure Feed

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

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