From 70c0d9ef5833db7c4f53f422178aeaab79397a1e Mon Sep 17 00:00:00 2001 From: crimx Date: Tue, 21 Mar 2023 17:02:11 +0800 Subject: [PATCH] feat(rtc): add TrackBoundary --- .../agora-rtc-react/.storybook/preview.js | 1 + .../src/components/LocalAudioTrack.tsx | 2 +- .../src/components/LocalVideoTrack.tsx | 2 +- .../src/components/RemoteAudioTrack.tsx | 2 +- .../src/components/RemoteVideoTrack.tsx | 2 +- .../TrackBoundary.stories.tsx | 128 ++++++++++-------- .../src/components/TrackBoundary.tsx | 96 +++++++++++++ .../agora-rtc-react/src/components/index.ts | 1 + .../agora-rtc-react/src/hooks/context.tsx | 2 +- .../agora-rtc-react/src/hooks/internal.tsx | 63 --------- 10 files changed, 177 insertions(+), 122 deletions(-) rename packages/agora-rtc-react/src/{stories => components}/TrackBoundary.stories.tsx (55%) create mode 100644 packages/agora-rtc-react/src/components/TrackBoundary.tsx delete mode 100644 packages/agora-rtc-react/src/hooks/internal.tsx diff --git a/packages/agora-rtc-react/.storybook/preview.js b/packages/agora-rtc-react/.storybook/preview.js index beb1b697..3682ca3f 100644 --- a/packages/agora-rtc-react/.storybook/preview.js +++ b/packages/agora-rtc-react/.storybook/preview.js @@ -22,6 +22,7 @@ export const parameters = { }, actions: { argTypesRegex: "^on[A-Z].*" }, controls: { + expanded: true, matchers: { color: /(background|color)$/i, date: /Date$/, diff --git a/packages/agora-rtc-react/src/components/LocalAudioTrack.tsx b/packages/agora-rtc-react/src/components/LocalAudioTrack.tsx index 9c3268e2..0d21ac43 100644 --- a/packages/agora-rtc-react/src/components/LocalAudioTrack.tsx +++ b/packages/agora-rtc-react/src/components/LocalAudioTrack.tsx @@ -4,7 +4,7 @@ import type { MaybePromiseOrNull } from "../utils"; import { useEffect } from "react"; import { useAwaited } from "../hooks"; -import { useAutoStopTrack } from "../hooks/internal"; +import { useAutoStopTrack } from "./TrackBoundary"; export interface LocalAudioTrackProps { /** diff --git a/packages/agora-rtc-react/src/components/LocalVideoTrack.tsx b/packages/agora-rtc-react/src/components/LocalVideoTrack.tsx index a435de67..af205f71 100644 --- a/packages/agora-rtc-react/src/components/LocalVideoTrack.tsx +++ b/packages/agora-rtc-react/src/components/LocalVideoTrack.tsx @@ -4,7 +4,7 @@ import type { MaybePromiseOrNull } from "../utils"; import { useEffect, useState } from "react"; import { useAwaited } from "../hooks"; -import { useAutoStopTrack } from "../hooks/internal"; +import { useAutoStopTrack } from "./TrackBoundary"; export interface LocalVideoTrackProps extends HTMLProps { /** diff --git a/packages/agora-rtc-react/src/components/RemoteAudioTrack.tsx b/packages/agora-rtc-react/src/components/RemoteAudioTrack.tsx index f69128f0..3d93dcea 100644 --- a/packages/agora-rtc-react/src/components/RemoteAudioTrack.tsx +++ b/packages/agora-rtc-react/src/components/RemoteAudioTrack.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import type { Nullable } from "../utils"; import { useEffect } from "react"; -import { useAutoStopTrack } from "../hooks/internal"; +import { useAutoStopTrack } from "./TrackBoundary"; export interface RemoteAudioTrackProps { /** diff --git a/packages/agora-rtc-react/src/components/RemoteVideoTrack.tsx b/packages/agora-rtc-react/src/components/RemoteVideoTrack.tsx index ee1d2cea..4c4a763e 100644 --- a/packages/agora-rtc-react/src/components/RemoteVideoTrack.tsx +++ b/packages/agora-rtc-react/src/components/RemoteVideoTrack.tsx @@ -3,7 +3,7 @@ import type { HTMLProps } from "react"; import type { Nullable } from "../utils"; import { useEffect, useState } from "react"; -import { useAutoStopTrack } from "../hooks/internal"; +import { useAutoStopTrack } from "./TrackBoundary"; export interface RemoteVideoTrackProps extends HTMLProps { /** diff --git a/packages/agora-rtc-react/src/stories/TrackBoundary.stories.tsx b/packages/agora-rtc-react/src/components/TrackBoundary.stories.tsx similarity index 55% rename from packages/agora-rtc-react/src/stories/TrackBoundary.stories.tsx rename to packages/agora-rtc-react/src/components/TrackBoundary.stories.tsx index c116c4e9..652c5a3b 100644 --- a/packages/agora-rtc-react/src/stories/TrackBoundary.stories.tsx +++ b/packages/agora-rtc-react/src/components/TrackBoundary.stories.tsx @@ -5,40 +5,43 @@ import { randUuid } from "@ngneat/falso"; import { action } from "@storybook/addon-actions"; import { createFakeRtcClient, FakeRemoteAudioTrack, FakeRemoteVideoTrack } from "fake-agora-rtc"; import { useState } from "react"; -import { RemoteUser } from "../components"; +import { RemoteUser } from "."; import { AgoraRTCProvider, TrackBoundary } from "../hooks"; -function logTrackStop(track: ITrack, onStop: () => void) { - const realStop = track.stop; - track.stop = function stop() { - onStop(); - realStop.call(this); - }; -} - -interface DirectionSwitchProps { +interface Controls { direction: "row" | "column"; - onChange: (direction: "row" | "column") => void; -} - -function DirectionSwitch({ direction, onChange }: DirectionSwitchProps) { - return ( -
- -
- ); + show: boolean; } const meta: Meta = { - title: "Recipes/TrackBoundary", + title: "Prebuilt/TrackBoundary", tags: ["autodocs"], + component: TrackBoundary, + argTypes: { + direction: { + name: "Layout Direction", + description: "[Demo Only] Horizontal or vertical layout", + table: { + defaultValue: { summary: "row" }, + }, + type: "string", + options: ["row", "column"], + control: { type: "select" }, + }, + show: { + name: "Show", + description: "[Demo Only] Show or hide the entire component", + table: { + defaultValue: { summary: "true" }, + }, + type: "boolean", + control: { type: "boolean" }, + }, + }, + args: { + direction: "row", + show: true, + }, decorators: [ Story => { const [client] = useState(() => @@ -67,48 +70,65 @@ const meta: Meta = { export default meta; -export const LayoutSwitchWithTrackBoundary: StoryObj = { - render: function LayoutSwitchWithTrackBoundary() { +export const LayoutSwitchWithTrackBoundary: StoryObj = { + parameters: { + docs: { + description: { + story: + "With TrackBoundary, Track Players will not trigger `track.stop()` on unmount. Tracks will be stopped if inactive or TrackBoundary unmounts.", + }, + }, + }, + render: function LayoutSwitchWithTrackBoundary({ direction, show }) { const [users] = useState(() => [ { uid: randUuid(), hasVideo: true, hasAudio: true }, { uid: randUuid(), hasVideo: true, hasAudio: true }, ]); - const [direction, setDirection] = useState<"row" | "column">("row"); - - return ( -
- - -
- {users.map(user => ( - - ))} -
-
-
+ return show ? ( + +
+ {users.map(user => ( + + ))} +
+
+ ) : ( + <> ); }, }; -export const LayoutSwitchWithoutTrackBoundary: StoryObj = { - render: function LayoutSwitchWithoutTrackBoundary() { +export const LayoutSwitchWithoutTrackBoundary: StoryObj = { + parameters: { + docs: { + description: { + story: "Without TrackBoundary, Track Players will trigger `track.stop()` on unmount.", + }, + }, + }, + render: function LayoutSwitchWithoutTrackBoundary({ direction, show }) { const [users] = useState(() => [ { uid: randUuid(), hasVideo: true, hasAudio: true }, { uid: randUuid(), hasVideo: true, hasAudio: true }, ]); - const [direction, setDirection] = useState<"row" | "column">("row"); - - return ( -
- -
- {users.map(user => ( - - ))} -
+ return show ? ( +
+ {users.map(user => ( + + ))}
+ ) : ( + <> ); }, }; + +function logTrackStop(track: ITrack, onStop: () => void) { + const realStop = track.stop; + track.stop = function stop() { + onStop(); + realStop.call(this); + }; +} diff --git a/packages/agora-rtc-react/src/components/TrackBoundary.tsx b/packages/agora-rtc-react/src/components/TrackBoundary.tsx new file mode 100644 index 00000000..cf8b3552 --- /dev/null +++ b/packages/agora-rtc-react/src/components/TrackBoundary.tsx @@ -0,0 +1,96 @@ +import type { ITrack } from "agora-rtc-sdk-ng"; +import type { PropsWithChildren } from "react"; +import type { Nullable } from "../utils"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { interval } from "../utils"; +import { useIsomorphicLayoutEffect } from "../hooks/tools"; + +interface TrackBoundaryController { + tracks: Map; + report: (track: ITrack) => () => void; + start: () => () => void; +} + +function createTrackBoundaryController(): TrackBoundaryController { + const tracks = new Map(); + const CLEAR_INTERVAL = 10000; + const REPORT_INTERVAL = 1000; + + const stopTracks = (force?: boolean) => { + const now = Date.now(); + for (const [track, timestamp] of tracks) { + if (force || now - timestamp > CLEAR_INTERVAL) { + track.stop(); + tracks.delete(track); + } + } + }; + + return { + tracks, + report: track => { + tracks.set(track, Date.now()); + return interval(() => tracks.set(track, Date.now()), REPORT_INTERVAL); + }, + start: () => { + const disposer = interval(stopTracks, CLEAR_INTERVAL); + return () => { + disposer(); + stopTracks(true); + }; + }, + }; +} + +const TrackBoundaryContext = /* @__PURE__ */ createContext( + void 0, +); + +/** + * Delegates track stop of descendant Track Players. + * This prevents track stops on Track Players unmounts due to re-layout. + * + * @example + * ```jsx + * + * + * + * + * ``` + * + * @example + * ```jsx + * + * + * + * + * ``` + */ +export function TrackBoundary({ children }: PropsWithChildren) { + const [controller] = useState(createTrackBoundaryController); + + useEffect(() => controller.start(), [controller]); + + return ( + {children} + ); +} + +/** + * Stops local or remote track when the component unmounts. + * If `` exists in ancestor it will not stop track on unmount but delegates to TrackBoundary. + */ +export function useAutoStopTrack(track: Nullable) { + const controller = useContext(TrackBoundaryContext); + + useIsomorphicLayoutEffect(() => { + if (track) { + if (controller) { + return controller.report(track); + } else { + return () => track.stop(); + } + } + }, [track, controller]); +} diff --git a/packages/agora-rtc-react/src/components/index.ts b/packages/agora-rtc-react/src/components/index.ts index 601eeaa4..917e0edc 100644 --- a/packages/agora-rtc-react/src/components/index.ts +++ b/packages/agora-rtc-react/src/components/index.ts @@ -9,3 +9,4 @@ export * from "./RemoteUser"; export * from "./MicControl"; export * from "./CameraControl"; export * from "./UserCover"; +export * from "./TrackBoundary"; diff --git a/packages/agora-rtc-react/src/hooks/context.tsx b/packages/agora-rtc-react/src/hooks/context.tsx index 388d8c15..9bfd5d85 100644 --- a/packages/agora-rtc-react/src/hooks/context.tsx +++ b/packages/agora-rtc-react/src/hooks/context.tsx @@ -2,7 +2,7 @@ import type { IAgoraRTCClient } from "agora-rtc-sdk-ng"; import type { PropsWithChildren } from "react"; import { createContext, useContext } from "react"; -export { TrackBoundary } from "./internal"; +export { TrackBoundary } from "../components/TrackBoundary"; const AgoraRTCContext = /* @__PURE__ */ createContext(null); diff --git a/packages/agora-rtc-react/src/hooks/internal.tsx b/packages/agora-rtc-react/src/hooks/internal.tsx deleted file mode 100644 index 799125ee..00000000 --- a/packages/agora-rtc-react/src/hooks/internal.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ITrack } from "agora-rtc-sdk-ng"; -import type { PropsWithChildren } from "react"; -import type { Nullable } from "../utils"; - -import { createContext, useContext, useEffect, useState } from "react"; -import { useIsomorphicLayoutEffect } from "./tools"; - -export const TrackBoundaryContext = /* @__PURE__ */ createContext | undefined>(void 0); - -/** - * Delegates track stop of descendant Track Players. - * This prevents track stops on track players unmounting. - * - * @example - * ```jsx - * - * - * - * - * ``` - * - * @example - * ```jsx - * - * - * - * - * ``` - */ -export function TrackBoundary({ children }: PropsWithChildren) { - const [tracks] = useState(() => new Set()); - - useEffect( - () => () => { - for (const track of tracks) { - track.stop(); - } - }, - [tracks], - ); - - return {children}; -} - -/** - * Releases local or remote track when the component unmounts. - */ -export function useAutoStopTrack(track: Nullable) { - const tracks = useContext(TrackBoundaryContext); - - useIsomorphicLayoutEffect(() => { - if (track) { - if (tracks) { - tracks.add(track); - return () => { - tracks.delete(track); - }; - } else { - return () => track.stop(); - } - } - }, [track, tracks]); -}