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 { 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};