Skip to content

Commit

Permalink
feat(rtc): add TrackBoundary
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed Mar 21, 2023
1 parent e74e40e commit 70c0d9e
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 122 deletions.
1 change: 1 addition & 0 deletions packages/agora-rtc-react/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const parameters = {
},
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style={{ padding: "4px 0" }}>
<label>
<input
type="checkbox"
checked={direction === "row"}
onChange={ev => onChange(ev.target.checked ? "row" : "column")}
/>
<span>Switch Layout</span>
</label>
</div>
);
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(() =>
Expand Down Expand Up @@ -67,48 +70,65 @@ const meta: Meta = {

export default meta;

export const LayoutSwitchWithTrackBoundary: StoryObj = {
render: function LayoutSwitchWithTrackBoundary() {
export const LayoutSwitchWithTrackBoundary: StoryObj<Controls> = {
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<IAgoraRTCRemoteUser[]>(() => [
{ uid: randUuid(), hasVideo: true, hasAudio: true },
{ uid: randUuid(), hasVideo: true, hasAudio: true },
]);

const [direction, setDirection] = useState<"row" | "column">("row");

return (
<div>
<DirectionSwitch direction={direction} onChange={setDirection} />
<TrackBoundary>
<div style={{ display: "flex", gap: 8, flexDirection: direction }}>
{users.map(user => (
<RemoteUser key={direction + user.uid} playAudio playVideo user={user} />
))}
</div>
</TrackBoundary>
</div>
return show ? (
<TrackBoundary>
<div style={{ display: "flex", gap: 8, flexDirection: direction }}>
{users.map(user => (
<RemoteUser key={direction + user.uid} playAudio playVideo user={user} />
))}
</div>
</TrackBoundary>
) : (
<></>
);
},
};

export const LayoutSwitchWithoutTrackBoundary: StoryObj = {
render: function LayoutSwitchWithoutTrackBoundary() {
export const LayoutSwitchWithoutTrackBoundary: StoryObj<Controls> = {
parameters: {
docs: {
description: {
story: "Without TrackBoundary, Track Players will trigger `track.stop()` on unmount.",
},
},
},
render: function LayoutSwitchWithoutTrackBoundary({ direction, show }) {
const [users] = useState<IAgoraRTCRemoteUser[]>(() => [
{ uid: randUuid(), hasVideo: true, hasAudio: true },
{ uid: randUuid(), hasVideo: true, hasAudio: true },
]);

const [direction, setDirection] = useState<"row" | "column">("row");

return (
<div>
<DirectionSwitch direction={direction} onChange={setDirection} />
<div style={{ display: "flex", gap: 8, flexDirection: direction }}>
{users.map(user => (
<RemoteUser key={direction + user.uid} playAudio playVideo user={user} />
))}
</div>
return show ? (
<div style={{ display: "flex", gap: 8, flexDirection: direction }}>
{users.map(user => (
<RemoteUser key={direction + user.uid} playAudio playVideo user={user} />
))}
</div>
) : (
<></>
);
},
};

function logTrackStop(track: ITrack, onStop: () => void) {
const realStop = track.stop;
track.stop = function stop() {
onStop();
realStop.call(this);
};
}
96 changes: 96 additions & 0 deletions packages/agora-rtc-react/src/components/TrackBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<ITrack, number>;
report: (track: ITrack) => () => void;
start: () => () => void;
}

function createTrackBoundaryController(): TrackBoundaryController {
const tracks = new Map<ITrack, number>();
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<TrackBoundaryController | undefined>(
void 0,
);

/**
* Delegates track stop of descendant Track Players.
* This prevents track stops on Track Players unmounts due to re-layout.
*
* @example
* ```jsx
* <TrackBoundary>
* <RemoteUser user={user1} />
* <RemoteUser user={user2} />
* </TrackBoundary>
* ```
*
* @example
* ```jsx
* <TrackBoundary>
* <RemoteVideoTrack track={track1} />
* <RemoteVideoTrack track={track2} />
* </TrackBoundary>
* ```
*/
export function TrackBoundary({ children }: PropsWithChildren) {
const [controller] = useState(createTrackBoundaryController);

useEffect(() => controller.start(), [controller]);

return (
<TrackBoundaryContext.Provider value={controller}>{children}</TrackBoundaryContext.Provider>
);
}

/**
* Stops local or remote track when the component unmounts.
* If `<TrackBoundary />` exists in ancestor it will not stop track on unmount but delegates to TrackBoundary.
*/
export function useAutoStopTrack(track: Nullable<ITrack>) {
const controller = useContext(TrackBoundaryContext);

useIsomorphicLayoutEffect(() => {
if (track) {
if (controller) {
return controller.report(track);
} else {
return () => track.stop();
}
}
}, [track, controller]);
}
1 change: 1 addition & 0 deletions packages/agora-rtc-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./RemoteUser";
export * from "./MicControl";
export * from "./CameraControl";
export * from "./UserCover";
export * from "./TrackBoundary";
2 changes: 1 addition & 1 deletion packages/agora-rtc-react/src/hooks/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IAgoraRTCClient | null>(null);

Expand Down
63 changes: 0 additions & 63 deletions packages/agora-rtc-react/src/hooks/internal.tsx

This file was deleted.

0 comments on commit 70c0d9e

Please sign in to comment.