Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add React utility hooks #51

Merged
merged 10 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading