Skip to content

Commit

Permalink
Merge pull request #51 from THEOplayer/react-utility-hooks
Browse files Browse the repository at this point in the history
Add React utility hooks
  • Loading branch information
MattiasBuelens authored Feb 14, 2024
2 parents cb10924 + 4fd1c0d commit 7997aa8
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 61 deletions.
56 changes: 29 additions & 27 deletions docs/examples/react.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

<script type="text/babel" data-type="module">
import * as React from 'react';
import { useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useState, useSyncExternalStore } from 'react';
import { createRoot } from 'react-dom/client';
import * as THEOplayerReactUI from '@theoplayer/react-ui';

Expand Down Expand Up @@ -80,42 +80,44 @@

// A custom React hook using THEOplayer.
// This hook returns the player's current time, automatically updating whenever it changes.
// (Alternatively, you can use the built-in `useCurrentTime` hook.)
const useCurrentTime = () => {
// Retrieve the THEOplayer instance.
// This will become available as soon as this component is mounted
// inside a THEOplayer <DefauLtUI> <UIContainer>.
// inside a THEOplayer <DefauLtUI> or <UIContainer>.
const player = useContext(THEOplayerReactUI.PlayerContext);
// Keep track of the player's current time.
const [time, setTime] = useState(0);
useEffect(() => {
const onTimeChange = () => {
setTime(player.currentTime);
};
player?.addEventListener(['timeupdate', 'seeking'], onTimeChange);
return () => player?.removeEventListener(['timeupdate', 'seeking'], onTimeChange);
}, [player]);
return time;
const subscribe = useCallback(
(callback) => {
player?.addEventListener(['timeupdate', 'seeking', 'seeked'], callback);
return () => player?.removeEventListener(['timeupdate', 'seeking', 'seeked'], callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => player?.currentTime ?? 0,
() => 0
);
};

// Returns whether the player has (previously) started playback for this stream.
// Can be used to show/hide certain initial controls, such as a poster image or a centered play button.
const useFirstPlay = () => {
const player = useContext(THEOplayerReactUI.PlayerContext);
const source = THEOplayerReactUI.useSource();
const paused = THEOplayerReactUI.usePaused();
const [firstPlay, setFirstPlay] = useState(false);
useEffect(() => {
const onPlay = () => {
setFirstPlay(true);
};
const onSourceChange = () => {
setFirstPlay(!player.paused);
};
player?.addEventListener('play', onPlay);
player?.addEventListener('sourcechange', onSourceChange);
return () => {
player?.removeEventListener('play', onPlay);
player?.removeEventListener('sourcechange', onSourceChange);
};
}, [player]);

// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevSource, setPrevSource] = useState(source);
if (source !== prevSource) {
setPrevSource(source);
setFirstPlay(false);
}
if (!firstPlay && !paused) {
setFirstPlay(true);
}

return firstPlay;
};

Expand All @@ -132,7 +134,7 @@
// A custom React component using THEOplayer.
const MyTimeDisplay = () => {
const time = useCurrentTime();
return <span className="my-time-display">{time}</span>;
return <span className="my-time-display">{time.toFixed(3)}</span>;
};

const Spacer = () => {
Expand Down
2 changes: 2 additions & 0 deletions react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
- 🚀 Added support for loading in Node for static site generation (SSG) or server-side rendering (SSR). ([#50](https://github.com/THEOplayer/web-ui/pull/50))
- This allows you to pass React components (such as `<DefaultUI>`, `<UIContainer>` or `<PlayButton>`) to the [Server React DOM APIs](https://react.dev/reference/react-dom/server), or to use them with a framework that supports SSG or SSR (such as Next.js, Remix or Gatsby).
- ⚠️ The rendered HTML must still be [hydrated](https://react.dev/reference/react-dom/client/hydrateRoot#hydrating-server-rendered-html) on the client to load the Open Video UI properly. (Usually, this handled automatically by your React framework.)
- 🚀 Added utility hooks such as `useCurrentTime()`, `usePaused()` and `useVolume()`. ([#51](https://github.com/THEOplayer/web-ui/pull/51))
- See [the API documentation](https://theoplayer.github.io/web-ui/react-api/) for more information.

## v1.6.0 (2024-02-08)

Expand Down
7 changes: 4 additions & 3 deletions react/src/DefaultUI.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { type PropsWithoutRef, type ReactNode } from 'react';
import { type PropsWithoutRef, type ReactNode, useState } from 'react';
import { DefaultUI as DefaultUIElement } from '@theoplayer/web-ui';
import type { ChromelessPlayer } from 'theoplayer/chromeless';
import { createComponent, type WebComponentProps } from '@lit/react';
Expand Down Expand Up @@ -88,9 +88,10 @@ export interface DefaultUIProps extends PropsWithoutRef<Omit<WebComponentProps<D
*/
export const DefaultUI = (props: DefaultUIProps) => {
const { title, topControlBar, bottomControlBar, menu, onReady, ...otherProps } = props;
const { player, setUi, onReadyHandler } = usePlayer(onReady);
const [ui, setUi] = useState<DefaultUIElement | null>(null);
const player = usePlayer(ui, onReady);
return (
<RawDefaultUI {...otherProps} ref={setUi} onReady={onReadyHandler}>
<RawDefaultUI {...otherProps} ref={setUi}>
<PlayerContext.Provider value={player}>
<Slotted slot="title">{title}</Slotted>
<Slotted slot="top-control-bar">{topControlBar}</Slotted>
Expand Down
7 changes: 4 additions & 3 deletions react/src/UIContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { type PropsWithoutRef, type ReactNode } from 'react';
import { type PropsWithoutRef, type ReactNode, useState } from 'react';
import { UIContainer as UIContainerElement } from '@theoplayer/web-ui';
import type { ChromelessPlayer } from 'theoplayer/chromeless';
import { createComponent, type WebComponentProps } from '@lit/react';
Expand Down Expand Up @@ -129,9 +129,10 @@ export interface UIContainerProps extends PropsWithoutRef<WebComponentProps<UICo
*/
export const UIContainer = (props: UIContainerProps) => {
const { topChrome, middleChrome, bottomChrome, centeredChrome, centeredLoading, menu, error, onReady, ...otherProps } = props;
const { player, setUi, onReadyHandler } = usePlayer(onReady);
const [ui, setUi] = useState<UIContainerElement | null>(null);
const player = usePlayer(ui, onReady);
return (
<RawUIContainer {...otherProps} ref={setUi} onReady={onReadyHandler}>
<RawUIContainer {...otherProps} ref={setUi}>
<PlayerContext.Provider value={player}>
<Slotted slot="top-chrome">{topChrome}</Slotted>
<Slotted slot="middle-chrome">{middleChrome}</Slotted>
Expand Down
31 changes: 19 additions & 12 deletions react/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,32 @@ import type { ChromelessPlayer } from 'theoplayer/chromeless';
* This can be used to access the backing player's state and add event listeners from within a custom React component.
* The component *must* be a child of {@link DefaultUI} or {@link UIContainer} in order to receive the context.
* ```jsx
* import { useContext, useState } from 'react';
* import { useCallback, useContext, useSyncExternalStore } from 'react';
* import { PlayerContext } from '@theoplayer/react-ui';
*
* const MyCustomComponent = () => {
* // Retrieve the backing player from the context
* const player = useContext(PlayerContext);
*
* // Track the paused state of the player
* const [paused, setPaused] = useState(player?.paused ?? true);
* useEffect(() => {
* if (!player) return;
* const updatePaused = () => {
* setPaused(player.paused);
* };
* player.addEventListener(['play', 'pause'], updatePaused);
* return () => {
* player.removeEventListener(['play', 'pause'], updatePaused);
* };
* }, [player]);
* const subscribe = useCallback(
* (callback) => {
* player?.addEventListener(['play', 'pause'], callback);
* return () => {
* player?.removeEventListener(['play', 'pause'], callback);
* };
* },
* [player]
* );
* const paused = useSyncExternalStore(
* subscribe,
* () => player?.paused ?? true,
* () => true
* );
*
* // Alternatively, you can use the built-in hook:
* // import { usePaused } from '@theoplayer/react-ui';
* // const paused = usePaused();
*
* // Show the paused state in your UI
* return <p>Player is {paused ? "paused" : "playing"}</p>
Expand Down
7 changes: 7 additions & 0 deletions react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from './useCurrentTime';
export * from './useDuration';
export * from './useMuted';
export * from './usePaused';
export * from './useSeeking';
export * from './useSource';
export * from './useVolume';
29 changes: 29 additions & 0 deletions react/src/hooks/useCurrentTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';
import type { PlayerEventMap } from 'theoplayer';

const TIME_CHANGE_EVENTS = ['timeupdate', 'seeking', 'seeked', 'emptied'] satisfies ReadonlyArray<keyof PlayerEventMap>;

/**
* Returns {@link theoplayer!ChromelessPlayer.currentTime | the player's current time}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useCurrentTime(): number {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener(TIME_CHANGE_EVENTS, callback);
return () => player?.removeEventListener(TIME_CHANGE_EVENTS, callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.currentTime : 0),
() => 0
);
}
29 changes: 29 additions & 0 deletions react/src/hooks/useDuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';
import type { PlayerEventMap } from 'theoplayer';

const DURATION_CHANGE_EVENTS = ['durationchange', 'emptied'] satisfies ReadonlyArray<keyof PlayerEventMap>;

/**
* Returns {@link theoplayer!ChromelessPlayer.duration | the player's duration}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useDuration(): number {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener(DURATION_CHANGE_EVENTS, callback);
return () => player?.removeEventListener(DURATION_CHANGE_EVENTS, callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.duration : NaN),
() => NaN
);
}
26 changes: 26 additions & 0 deletions react/src/hooks/useMuted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';

/**
* Returns {@link theoplayer!ChromelessPlayer.muted | the player's muted state}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useMuted(): boolean {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener('volumechange', callback);
return () => player?.removeEventListener('volumechange', callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.muted : false),
() => false
);
}
29 changes: 29 additions & 0 deletions react/src/hooks/usePaused.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';
import type { PlayerEventMap } from 'theoplayer';

const PAUSED_CHANGE_EVENTS = ['play', 'pause'] satisfies ReadonlyArray<keyof PlayerEventMap>;

/**
* Returns {@link theoplayer!ChromelessPlayer.paused | the player's paused state}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function usePaused(): boolean {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener(PAUSED_CHANGE_EVENTS, callback);
return () => player?.removeEventListener(PAUSED_CHANGE_EVENTS, callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.paused : true),
() => true
);
}
29 changes: 29 additions & 0 deletions react/src/hooks/useSeeking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';
import type { PlayerEventMap } from 'theoplayer';

const SEEKING_CHANGE_EVENTS = ['seeking', 'seeked', 'emptied'] satisfies ReadonlyArray<keyof PlayerEventMap>;

/**
* Returns {@link theoplayer!ChromelessPlayer.seeking | the player's seeking state}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useSeeking(): boolean {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener(SEEKING_CHANGE_EVENTS, callback);
return () => player?.removeEventListener(SEEKING_CHANGE_EVENTS, callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.seeking : false),
() => false
);
}
27 changes: 27 additions & 0 deletions react/src/hooks/useSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';
import type { SourceDescription } from 'theoplayer';

/**
* Returns {@link theoplayer!ChromelessPlayer.source | the player's source}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useSource(): SourceDescription | undefined {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener('sourcechange', callback);
return () => player?.removeEventListener('sourcechange', callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => player?.source,
() => undefined
);
}
26 changes: 26 additions & 0 deletions react/src/hooks/useVolume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useCallback, useContext, useSyncExternalStore } from 'react';
import { PlayerContext } from '../context';

/**
* Returns {@link theoplayer!ChromelessPlayer.volume | the player's volume}, automatically updating whenever it changes.
*
* This hook must only be used in a component mounted inside a {@link DefaultUI} or {@link UIContainer},
* or alternatively any other component that provides a {@link PlayerContext}.
*
* @group Hooks
*/
export function useVolume(): number {
const player = useContext(PlayerContext);
const subscribe = useCallback(
(callback: () => void) => {
player?.addEventListener('volumechange', callback);
return () => player?.removeEventListener('volumechange', callback);
},
[player]
);
return useSyncExternalStore(
subscribe,
() => (player ? player.volume : 1),
() => 1
);
}
1 change: 1 addition & 0 deletions react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { PlayerContext } from './context';
export * from './UIContainer';
export * from './DefaultUI';
export * from './components/index';
export * from './hooks/index';
Loading

0 comments on commit 7997aa8

Please sign in to comment.