Live video on the AT Protocol
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};