> = {};
+ for (const playerId of initialPlayers) {
+ initialPlayerStores[playerId] = makePlayerStore(playerId);
+ }
+
+ // Always create at least one player by default
+ if (initialPlayers.length === 0) {
+ const defaultId = Math.random().toString(36).slice(8);
+ initialPlayerStores[defaultId] = makePlayerStore(defaultId);
+ }
+
+ return initialPlayerStores;
+ },
+ );
+
+ const createPlayer = useCallback((id?: string) => {
+ console.log("Creating new player");
+ const playerId = id || Math.random().toString(36).slice(8);
+ const playerStore = makePlayerStore(playerId);
+
+ setPlayers((prev) => ({
+ ...prev,
+ [playerId]: playerStore,
+ }));
+
+ return playerId;
+ }, []);
+
+ const removePlayer = useCallback((id: string) => {
+ setPlayers((prev) => {
+ // Don't remove the last player
+ if (Object.keys(prev).length <= 1) {
+ console.warn("Cannot remove the last player");
+ return prev;
+ }
+
+ const newPlayers = { ...prev };
+ delete newPlayers[id];
+ return newPlayers;
+ });
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ players,
+ createPlayer,
+ removePlayer,
+ }),
+ [players, createPlayer, removePlayer],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+// HOC to wrap components that need player context
+export function withPlayerProvider(
+ Component: React.ComponentType
,
+): React.FC
{
+ return function WithPlayerProvider(props: P & { initialPlayers?: string[] }) {
+ const { initialPlayers, ...componentProps } = props;
+ return (
+
+
+
+ );
+ };
+}
diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx
index 08edae1cd..324bed233 100644
--- a/js/components/src/player-store/player-state.tsx
+++ b/js/components/src/player-store/player-state.tsx
@@ -4,9 +4,22 @@ export enum PlayerProtocol {
PLAYER_PROTOCOL_PROGRESSIVE_MP4 = "progressive-mp4",
PLAYER_PROTOCOL_PROGRESSIVE_WEBM = "progressive-webm",
}
-export interface PlayersState {
- [key: string]: PlayerState;
+
+export enum PlayerStatus {
+ START = "start",
+ PLAYING = "playing",
+ STALLED = "stalled",
+ SUSPEND = "suspend",
+ WAITING = "waiting",
+ PAUSE = "pause",
+ MUTE = "mute",
+}
+
+export enum IngestMediaSource {
+ USER = "user",
+ DISPLAY = "display",
}
+
export interface PlayerState {
id: string;
selectedRendition: string;
@@ -20,15 +33,111 @@ export interface PlayerState {
/** Function to set the ingestStarting flag */
setIngestStarting: (ingestStarting: boolean) => void;
+ /** Ingest stream key for the current player */
+ ingestStreamKey: string;
+
+ /** Function to set the ingest stream key */
+ setIngestStreamKey: (ingestStreamKey: string) => void;
+
/** Current connection state of ingest RTP/RTC peer connection */
ingestConnectionState: RTCPeerConnectionState | null;
/** Function to update the ingest connection state */
setIngestConnectionState: (state: RTCPeerConnectionState | null) => void;
+ ingestMediaSource?: IngestMediaSource;
+ setIngestMediaSource?: (source: IngestMediaSource) => void;
+
+ ingestAutoStart?: boolean;
+ setIngestAutoStart?: (autoStart: boolean) => void;
+
/** Timestamp (number) when ingest started, or null if not started */
ingestStarted: number | null;
/** Function to set the ingestStarted timestamp */
setIngestStarted: (timestamp: number | null) => void;
+
+ /** Player muted state */
+ muted: boolean;
+
+ /** Function to set the muted state */
+ setMuted: (isMuted: boolean) => void;
+
+ /** Player volume level (0.0 to 1.0) */
+ volume: number;
+
+ /** Function to set the volume level */
+ setVolume: (volume: number) => void;
+
+ /** Player fullscreen state */
+ fullscreen: boolean;
+
+ /** Function to set the fullscreen state */
+ setFullscreen: (isFullscreen: boolean) => void;
+
+ /** Current player status */
+ status: PlayerStatus;
+
+ /** Function to update the player status */
+ setStatus: (status: PlayerStatus) => void;
+
+ /** Current playback time in seconds */
+ playTime: number;
+
+ /** Function to set the current playback time */
+ setPlayTime: (playTime: number) => void;
+
+ /** Flag indicating if player is in offline state */
+ offline: boolean;
+
+ /** Function to set the offline state */
+ setOffline: (offline: boolean) => void;
+ /** Reference to the video element for direct manipulation (used for PiP) */
+ videoRef:
+ | React.MutableRefObject
+ | ((instance: HTMLVideoElement | null) => void)
+ | null
+ | undefined;
+
+ /** Function to set the video reference */
+ setVideoRef: (
+ videoRef:
+ | React.MutableRefObject
+ | ((instance: HTMLVideoElement | null) => void)
+ | null
+ | undefined,
+ ) => void;
+
+ /** Flag indicating if player is in Picture-in-Picture mode */
+ pipMode: boolean;
+
+ /** Function to set the Picture-in-Picture mode */
+ setPipMode: (pipMode: boolean) => void;
+
+ /** Flag indicating if mute was forced by system (e.g., autoplay policy) */
+ muteWasForced: boolean;
+
+ /** Function to set the muteWasForced flag */
+ setMuteWasForced: (muteWasForced: boolean) => void;
+
+ /** Flag indicating if the player is embedded in another context */
+ embedded: boolean;
+
+ /** Function to set the embedded flag */
+ setEmbedded: (embedded: boolean) => void;
+
+ /** Flag indicating if player controls should be shown */
+ showControls: boolean;
+ controlsTimeout?: NodeJS.Timeout | undefined;
+
+ /** Function to set the showControls flag */
+ setShowControls: (showControls: boolean) => void;
+
+ playerEvent: (
+ time: string,
+ eventType: string,
+ meta: { [key: string]: any },
+ ) => void;
+
+ setUserInteraction: () => void;
}
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 77f821330..66f8ab598 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -1,14 +1,19 @@
import { useContext } from "react";
import { createStore, StoreApi, useStore } from "zustand";
-import { shallow } from "zustand/shallow"; // Import shallow
import { PlayerContext } from "./context";
-import { PlayerProtocol, PlayerState } from "./player-state";
+import {
+ IngestMediaSource,
+ PlayerProtocol,
+ PlayerState,
+ PlayerStatus,
+} from "./player-state";
+
export type PlayerStore = StoreApi;
-export const makePlayerStore = (): StoreApi => {
+export const makePlayerStore = (id?: string): StoreApi => {
return createStore()((set) => ({
- id: Math.random().toString(36).slice(8),
- selectedRendition: "auto",
+ id: id || Math.random().toString(36).slice(8),
+ selectedRendition: "source",
setSelectedRendition: (rendition: string) =>
set((state) => ({ ...state, selectedRendition: rendition })),
protocol: PlayerProtocol.PLAYER_PROTOCOL_WEBRTC,
@@ -19,40 +24,149 @@ export const makePlayerStore = (): StoreApi => {
setIngestStarting: (ingestStarting: boolean) =>
set(() => ({ ingestStarting })),
+ ingestMediaSource: undefined,
+ setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) =>
+ set(() => ({ ingestMediaSource })),
+
+ ingestStreamKey: "",
+ setIngestStreamKey: (ingestStreamKey: string) =>
+ set(() => ({ ingestStreamKey })),
+
ingestConnectionState: null,
setIngestConnectionState: (
ingestConnectionState: RTCPeerConnectionState | null,
) => set(() => ({ ingestConnectionState })),
+ ingestAutoStart: false,
+ setIngestAutoStart: (ingestAutoStart: boolean) =>
+ set(() => ({ ingestAutoStart })),
+
ingestStarted: null,
setIngestStarted: (timestamp: number | null) =>
set(() => ({ ingestStarted: timestamp })),
+
+ muted: false,
+ setMuted: (isMuted: boolean) => set(() => ({ muted: isMuted })),
+
+ volume: 1.0,
+ setVolume: (volume: number) => set(() => ({ volume })),
+
+ fullscreen: false,
+ setFullscreen: (isFullscreen: boolean) =>
+ set(() => ({ fullscreen: isFullscreen })),
+
+ status: PlayerStatus.START,
+ setStatus: (status: PlayerStatus) => set(() => ({ status })),
+
+ playTime: 0,
+ setPlayTime: (playTime: number) => set(() => ({ playTime })),
+
+ offline: false,
+ setOffline: (offline: boolean) => set(() => ({ offline })),
+
+ videoRef: undefined,
+ setVideoRef: (
+ videoRef:
+ | React.MutableRefObject
+ | ((instance: HTMLVideoElement | null) => void)
+ | null
+ | undefined,
+ ) => set(() => ({ videoRef })),
+
+ pipMode: false,
+ setPipMode: (pipMode: boolean) => set(() => ({ pipMode })),
+
+ muteWasForced: false,
+ setMuteWasForced: (muteWasForced: boolean) =>
+ set(() => ({ muteWasForced })),
+
+ embedded: false,
+ setEmbedded: (embedded: boolean) => set(() => ({ embedded })),
+
+ showControls: true,
+ controlsTimeout: undefined,
+ setShowControls: (showControls: boolean) =>
+ set({ showControls, controlsTimeout: undefined }),
+
+ playerEvent: async (
+ time: string,
+ eventType: string,
+ meta: { [key: string]: any },
+ ) => {
+ // just log for now, can be replaced with actual event handling
+ console.log(`Player Event: ${eventType} at ${time}`, meta);
+ },
+
+ setUserInteraction: () =>
+ set((p) => {
+ // controls timeout
+ if (p.controlsTimeout) {
+ clearTimeout(p.controlsTimeout);
+ }
+ let controlsTimeout = setTimeout(() => p.setShowControls(false), 1000);
+ return { showControls: true, controlsTimeout };
+ }),
}));
};
-export function getPlayerStoreFromContext(): PlayerStore {
+export function usePlayerContext() {
const context = useContext(PlayerContext);
if (!context) {
- throw new Error(
- "getPlayerStoreFromContext must be used within a PlayerProvider",
- );
+ throw new Error("usePlayerContext must be used within a PlayerProvider");
+ }
+ return context;
+}
+
+// Get a specific player store by ID
+export function getPlayerStoreById(id: string): PlayerStore {
+ const { players } = usePlayerContext();
+ const playerStore = players[id];
+ if (!playerStore) {
+ throw new Error(`No player found with ID: ${id}`);
+ }
+ return playerStore;
+}
+
+// Will get the first player ID in the context
+export function getFirstPlayerID(): string {
+ const { players } = usePlayerContext();
+ const playerIds = Object.keys(players);
+ if (playerIds.length === 0) {
+ throw new Error("No players found in context");
}
- return context.store;
+ return playerIds[0];
}
+export function getPlayerStoreFromContext(): PlayerStore {
+ console.warn(
+ "getPlayerStoreFromContext is deprecated. Use getPlayerStoreById instead.",
+ );
+ const { players } = usePlayerContext();
+ const playerIds = Object.keys(players);
+ if (playerIds.length === 0) {
+ throw new Error("No players found in context");
+ }
+ return players[playerIds[0]];
+}
+
+// Use a specific player store by ID
+// If no ID is provided, it will use the first player in the context
export function usePlayerStore(
selector: (state: PlayerState) => U,
- equals?: (a: U, b: U) => boolean,
+ playerId?: string,
): U {
- const store = getPlayerStoreFromContext();
- return useStore(store, selector); // Pass equality function
+ if (!playerId) {
+ playerId = Object.keys(usePlayerContext().players)[0];
+ }
+ const store = getPlayerStoreById(playerId);
+ return useStore(store, selector);
}
/* Convenience selectors/hooks */
-export const usePlayerProtocol = (): [
- PlayerProtocol,
- (protocol: PlayerProtocol) => void,
-] => usePlayerStore((x) => [x.protocol, x.setProtocol], shallow); // Use shallow comparison
+export const usePlayerProtocol = (
+ playerId?: string,
+): [PlayerProtocol, (protocol: PlayerProtocol) => void] =>
+ usePlayerStore((x) => [x.protocol, x.setProtocol], playerId);
export const intoPlayerProtocol = (protocol: string): PlayerProtocol => {
switch (protocol) {
@@ -66,11 +180,3 @@ export const intoPlayerProtocol = (protocol: string): PlayerProtocol => {
return PlayerProtocol.PLAYER_PROTOCOL_WEBRTC;
}
};
-
-export const usePlayerSelectedRendition = (): [
- string,
- (rendition: string) => void,
-] =>
- usePlayerStore((x) => [x.selectedRendition, x.setSelectedRendition], shallow); // Also apply shallow here if it returns an array/object
-
-export const usePlayerId = () => usePlayerStore((x) => x.id); // No shallow needed for primitive
diff --git a/js/components/src/player-store/single-player-provider.tsx b/js/components/src/player-store/single-player-provider.tsx
new file mode 100644
index 000000000..0445db419
--- /dev/null
+++ b/js/components/src/player-store/single-player-provider.tsx
@@ -0,0 +1,181 @@
+import React, { createContext, useContext, useMemo } from "react";
+import { StoreApi, useStore } from "zustand";
+import { usePlayerContext } from "../player-store";
+import { PlayerProtocol, PlayerState } from "./player-state";
+
+// Context for a single player
+interface SinglePlayerContextType {
+ playerId: string;
+ playerStore: StoreApi;
+}
+
+const SinglePlayerContext = createContext(null);
+
+interface SinglePlayerProviderProps {
+ children: React.ReactNode;
+ playerId?: string;
+ protocol?: PlayerProtocol;
+ rendition?: string;
+}
+
+/**
+ * Provider component for a single player that creates a scoped context
+ * This allows components to access a specific player's state without passing IDs around
+ */
+export const SinglePlayerProvider: React.FC = ({
+ children,
+ playerId: providedPlayerId,
+ protocol = PlayerProtocol.PLAYER_PROTOCOL_WEBRTC,
+ rendition = "auto",
+}) => {
+ const { players, createPlayer } = usePlayerContext();
+
+ // Create or get a player ID
+ const playerId = useMemo(() => {
+ // If a player ID is provided and exists, use it
+ if (providedPlayerId && players[providedPlayerId]) {
+ return providedPlayerId;
+ }
+
+ // If a player ID is provided but doesn't exist, create it
+ if (providedPlayerId) {
+ return createPlayer(providedPlayerId);
+ }
+
+ // Otherwise create a new player
+ return createPlayer();
+ }, [providedPlayerId, players, createPlayer]);
+
+ // Get the player store
+ const playerStore = useMemo(() => {
+ return players[playerId];
+ }, [players, playerId]);
+
+ // Set initial protocol and rendition if provided
+ React.useEffect(() => {
+ if (protocol) {
+ playerStore.setState((state) => ({
+ ...state,
+ protocol,
+ }));
+ }
+
+ if (rendition) {
+ playerStore.setState((state) => ({
+ ...state,
+ selectedRendition: rendition,
+ }));
+ }
+ }, [playerStore, protocol, rendition]);
+
+ // Create context value
+ const contextValue = useMemo(
+ () => ({
+ playerId,
+ playerStore,
+ }),
+ [playerId, playerStore],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Hook to access the current single player context
+ */
+export function useSinglePlayerContext() {
+ const context = useContext(SinglePlayerContext);
+ if (!context) {
+ throw new Error(
+ "useSinglePlayerContext must be used within a SinglePlayerProvider",
+ );
+ }
+ return context;
+}
+
+/**
+ * Hook to access the current player ID from the single player context
+ */
+export function useCurrentPlayerId(): string {
+ const { playerId } = useSinglePlayerContext();
+ return playerId;
+}
+
+/**
+ * Hook to access state from the current player without needing to specify the ID
+ */
+export function useCurrentPlayerStore(
+ selector: (state: PlayerState) => U,
+): U {
+ const { playerStore } = useSinglePlayerContext();
+ return useStore(playerStore, selector);
+}
+
+/**
+ * Hook to get the protocol of the current player
+ */
+export function useCurrentPlayerProtocol(): [
+ PlayerProtocol,
+ (protocol: PlayerProtocol) => void,
+] {
+ return useCurrentPlayerStore(
+ (state) => [state.protocol, state.setProtocol] as const,
+ );
+}
+
+/**
+ * Hook to get the selected rendition of the current player
+ */
+export function useCurrentPlayerRendition(): [
+ string,
+ (rendition: string) => void,
+] {
+ return useCurrentPlayerStore(
+ (state) => [state.selectedRendition, state.setSelectedRendition] as const,
+ );
+}
+
+/**
+ * Hook to get the ingest state of the current player
+ */
+export function useCurrentPlayerIngest(): {
+ starting: boolean;
+ setStarting: (starting: boolean) => void;
+ connectionState: RTCPeerConnectionState | null;
+ setConnectionState: (state: RTCPeerConnectionState | null) => void;
+ startedTimestamp: number | null;
+ setStartedTimestamp: (timestamp: number | null) => void;
+} {
+ return useCurrentPlayerStore((state) => ({
+ starting: state.ingestStarting,
+ setStarting: state.setIngestStarting,
+ connectionState: state.ingestConnectionState,
+ setConnectionState: state.setIngestConnectionState,
+ startedTimestamp: state.ingestStarted,
+ setStartedTimestamp: state.setIngestStarted,
+ }));
+}
+
+/**
+ * HOC to wrap components with a SinglePlayerProvider
+ */
+export function withSinglePlayer(
+ Component: React.ComponentType
,
+): React.FC
{
+ return function WithSinglePlayer(props: P & SinglePlayerProviderProps) {
+ const { playerId, protocol, rendition, ...componentProps } = props;
+ return (
+
+
+
+ );
+ };
+}