diff --git a/e2e/tests/create-production.spec.ts b/e2e/tests/create-production.spec.ts index 03772850..00a2d77c 100644 --- a/e2e/tests/create-production.spec.ts +++ b/e2e/tests/create-production.spec.ts @@ -73,8 +73,14 @@ test.describe("Create Production", () => { await createProductionPage.goto(); await createProductionPage.addLineButton.click(); // Both lines should have Audio Feed checkboxes + await expect(createProductionPage.page.getByText("Audio Feed")).toHaveCount( + 2 + ); + await expect( + createProductionPage.page.getByText("Video Enabled") + ).toHaveCount(2); const checkboxes = createProductionPage.page.getByRole("checkbox"); - await expect(checkboxes).toHaveCount(2); + await expect(checkboxes).toHaveCount(4); }); test("shows line numbers on cards", async ({ createProductionPage }) => { diff --git a/src/api/api.ts b/src/api/api.ts index 71ffb31e..78c630e2 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -33,7 +33,11 @@ export type TPreset = { type TCreateProductionOptions = { name: string; - lines: { name: string; programOutputLine?: boolean }[]; + lines: { + name: string; + programOutputLine?: boolean; + videoEnabled?: boolean; + }[]; }; type TParticipant = { @@ -42,6 +46,8 @@ type TParticipant = { endpointId: string; isActive: boolean; isWhip: boolean; + isWhepReceiver?: boolean; + hasVideo?: boolean; }; type TLine = { @@ -50,6 +56,8 @@ type TLine = { smbConferenceId: string; participants: TParticipant[]; programOutputLine?: boolean; + videoEnabled?: boolean; + whepSourceSessionId?: string | null; }; export type TBasicProductionResponse = { @@ -161,6 +169,45 @@ export const API = { }), }) ), + + setLineWhepSource: async ({ + productionId, + lineId, + sessionId, + }: { + productionId: string; + lineId: string; + sessionId: string | null; + }): Promise<{ lineId: string; pinnedSessionId: string | null }> => + handleFetchRequest<{ lineId: string; pinnedSessionId: string | null }>( + fetch(`${API_URL}production/${productionId}/line/${lineId}/whep-source`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ pinnedSessionId: sessionId }), + }) + ), + + setSessionVideoSource: async ({ + sessionId, + pinnedSessionId, + }: { + sessionId: string; + pinnedSessionId: string | null; + }): Promise<{ sessionId: string; pinnedSessionId: string | null }> => + handleFetchRequest<{ + sessionId: string; + pinnedSessionId: string | null; + }>( + fetch(`${API_URL}session/${sessionId}/video-source`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ pinnedSessionId }), + }) + ), listProductions: ({ searchParams, }: { @@ -215,7 +262,8 @@ export const API = { addProductionLine: ( productionId: string, name: string, - programOutputLine?: boolean + programOutputLine?: boolean, + videoEnabled?: boolean ): Promise => handleFetchRequest( fetch(`${API_URL}production/${productionId}/line`, { @@ -226,6 +274,7 @@ export const API = { body: JSON.stringify({ name, programOutputLine, + videoEnabled, }), }) ), @@ -337,12 +386,14 @@ export const API = { body: JSON.stringify(options), }) ), + listPresets: (): Promise<{ presets: TPreset[] }> => handleFetchRequest<{ presets: TPreset[] }>( fetch(`${API_URL}preset`, { method: "GET", }) ), + deletePreset: async (id: string): Promise => { const response = await fetch(`${API_URL}preset/${id}`, { method: "DELETE", @@ -351,6 +402,7 @@ export const API = { await handleFetchRequest(Promise.resolve(response)); } }, + updatePreset: ( id: string, update: { diff --git a/src/assets/icons/delete.svg b/src/assets/icons/delete.svg new file mode 100644 index 00000000..1f68f1bd --- /dev/null +++ b/src/assets/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/fullscreen.svg b/src/assets/icons/fullscreen.svg new file mode 100644 index 00000000..0621be1b --- /dev/null +++ b/src/assets/icons/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/fullscreen_exit.svg b/src/assets/icons/fullscreen_exit.svg new file mode 100644 index 00000000..3b718869 --- /dev/null +++ b/src/assets/icons/fullscreen_exit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 26aeeec1..eb3fffb2 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -25,6 +25,13 @@ import VolumeOff from "./volume_off.svg?react"; import VolumeOn from "./volume_on.svg?react"; import Warning from "./warning.svg?react"; import WhipSvg from "./whip_user.svg?react"; +import DeleteSvg from "./delete.svg?react"; +import VideoOn from "./video_on.svg?react"; +import VideoOff from "./video_off.svg?react"; +import Pin from "./pin.svg?react"; +import Unpin from "./unpin.svg?react"; +import Fullscreen from "./fullscreen.svg?react"; +import FullscreenExit from "./fullscreen_exit.svg?react"; export const MicMuted = () => ; @@ -79,3 +86,17 @@ export const SaveIcon = () => ; export const HelpIcon = () => ; export const WarningIcon = () => ; + +export const DeleteIcon = () => ; + +export const VideoOnIcon = () => ; + +export const VideoOffIcon = () => ; + +export const PinIcon = () => ; + +export const UnpinIcon = () => ; + +export const FullscreenIcon = () => ; + +export const FullscreenExitIcon = () => ; diff --git a/src/assets/icons/pin.svg b/src/assets/icons/pin.svg new file mode 100644 index 00000000..746c0ff8 --- /dev/null +++ b/src/assets/icons/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/unpin.svg b/src/assets/icons/unpin.svg new file mode 100644 index 00000000..676bfc00 --- /dev/null +++ b/src/assets/icons/unpin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/video_off.svg b/src/assets/icons/video_off.svg new file mode 100644 index 00000000..a6d3d37c --- /dev/null +++ b/src/assets/icons/video_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/video_on.svg b/src/assets/icons/video_on.svg new file mode 100644 index 00000000..98636eb6 --- /dev/null +++ b/src/assets/icons/video_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/calls-page/calls-page.tsx b/src/components/calls-page/calls-page.tsx index a183a542..01725524 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -31,6 +31,7 @@ import { ConfirmationModal } from "../verify-decision/confirmation-modal"; import { HeaderActions } from "./header-actions"; import { ProductionLines } from "./production-lines"; import { ProgramLineJoinCard } from "./program-line-join-card"; +import { VideoLineJoinCard } from "./video-line-join-card"; import { useCallsNavigation } from "./use-calls-navigation"; import { useGlobalMuteHotkey } from "./use-global-mute-hotkey"; import { usePreventPullToRefresh } from "./use-prevent-pull-to-refresh"; @@ -138,6 +139,16 @@ export const CallsPage = () => { const [pendingProgramLines, setPendingProgramLines] = useState< PendingProgramLine[] >([]); + type PendingVideoLine = { + productionId: string; + lineId: string; + lineName?: string; + productionName?: string; + }; + + const [pendingVideoRefs, setPendingVideoRefs] = useState( + [] + ); const { deregisterCall, registerCallList, @@ -155,6 +166,7 @@ export const CallsPage = () => { const { initiateProductionCall } = useInitiateProductionCall({ dispatch }); const autoJoinTriggeredRef = useRef(false); const pendingProgramKeysRef = useRef>(new Set()); + const pendingVideoKeysRef = useRef>(new Set()); const { productionId: paramProductionId, lineId: paramLineId } = useParams(); const { search } = useLocation(); @@ -278,6 +290,7 @@ export const CallsPage = () => { ); const program: PendingProgramLine[] = []; + const video: PendingVideoLine[] = []; const validRefs: typeof pendingCallRefs = []; pendingCallRefs.forEach((ref, i) => { @@ -295,6 +308,13 @@ export const CallsPage = () => { productionName: prod.name, lineName: line.name, }); + } else if (line.videoEnabled === true) { + video.push({ + productionId: ref.productionId, + lineId: ref.lineId, + productionName: prod.name, + lineName: line.name, + }); } }); @@ -305,6 +325,13 @@ export const CallsPage = () => { setPendingProgramLines(program); } + if (video.length > 0) { + pendingVideoKeysRef.current = new Set( + video.map((r) => `${r.productionId}:${r.lineId}`) + ); + setPendingVideoRefs(video); + } + if (validRefs.length !== pendingCallRefs.length) { navigate(buildCallsUrl(validRefs, autoCompanionUrl), { replace: true, @@ -331,6 +358,7 @@ export const CallsPage = () => { autoJoinTriggeredRef.current = true; const programKeys = pendingProgramKeysRef.current; + const videoKeys = pendingVideoKeysRef.current; const existingKeys = new Set( Object.values(calls) @@ -343,6 +371,7 @@ export const CallsPage = () => { .filter( (ref) => !programKeys.has(`${ref.productionId}:${ref.lineId}`) && + !videoKeys.has(`${ref.productionId}:${ref.lineId}`) && !existingKeys.has(`${ref.productionId}:${ref.lineId}`) ) .forEach((ref) => { @@ -577,6 +606,42 @@ export const CallsPage = () => { /> )} + {!!( + userSettings && + userSettings.username && + (userSettings.audioinput || userSettings.audiooutput) + ) && + pendingVideoRefs + .filter( + (ref) => + !Object.values(calls).some( + (c) => + c.joinProductionOptions?.productionId === + ref.productionId && + c.joinProductionOptions?.lineId === ref.lineId + ) + ) + .map((ref) => ( + + setPendingVideoRefs((prev) => + prev.filter( + (r) => + !( + r.productionId === ref.productionId && + r.lineId === ref.lineId + ) + ) + ) + } + /> + ))} {addCallActive && !isGuest && (productionId || addCallPreSelected) && ( diff --git a/src/components/calls-page/video-line-join-card.test.tsx b/src/components/calls-page/video-line-join-card.test.tsx new file mode 100644 index 00000000..fa67501f --- /dev/null +++ b/src/components/calls-page/video-line-join-card.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { VideoLineJoinCard } from "./video-line-join-card"; +import { TGlobalState } from "../../global-state/types"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("../../global-state/context-provider", () => ({ + useGlobalState: vi.fn(), +})); + +vi.mock("../../hooks/use-initiate-production-call", () => ({ + useInitiateProductionCall: vi.fn(), +})); + +vi.mock("../../hooks/use-video-device-labels", () => ({ + useVideoDeviceLabels: vi.fn(), +})); + +// ── Typed mock references ──────────────────────────────────────────────────── + +const mockUseGlobalState = vi.mocked(useGlobalState); +const mockUseInitiateProductionCall = vi.mocked(useInitiateProductionCall); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const camera = { + deviceId: "cam-1", + label: "Built-in Camera", + groupId: "group-1", + kind: "videoinput", + toJSON: () => ({}), +} as MediaDeviceInfo; + +const baseState: TGlobalState = { + calls: {}, + error: {}, + reloadProductionList: false, + reloadPresetList: false, + production: null, + selectedProductionId: null, + devices: { input: [], output: [], videoInput: [camera] }, + userSettings: null, + apiError: false, + websocket: null, +}; + +const mockDispatch = vi.fn(); +const mockInitiate = vi.fn(); + +const withUsername = (username: string | null): TGlobalState => ({ + ...baseState, + userSettings: username + ? { username, audioinput: "mic-1", audiooutput: "spk-1" } + : null, +}); + +const renderCard = () => + render( + + ); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("VideoLineJoinCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInitiate.mockResolvedValue(true); + mockUseInitiateProductionCall.mockReturnValue({ + initiateProductionCall: mockInitiate, + }); + mockUseGlobalState.mockReturnValue([withUsername("Alice"), mockDispatch]); + }); + + it("offers camera selection listing the available devices", () => { + renderCard(); + + expect( + screen.getByRole("combobox", { name: "Camera" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "No camera" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Built-in Camera" }) + ).toBeInTheDocument(); + }); + + it("does not gate joining behind a username field — it uses the stored one", () => { + renderCard(); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Join" })).not.toBeDisabled(); + }); + + it("disables Join when no username is stored", () => { + mockUseGlobalState.mockReturnValue([withUsername(null), mockDispatch]); + renderCard(); + + expect(screen.getByRole("button", { name: "Join" })).toBeDisabled(); + }); + + it("initiates the call with the chosen camera and video enabled", async () => { + renderCard(); + + fireEvent.change(screen.getByRole("combobox", { name: "Camera" }), { + target: { value: "cam-1" }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Join" })); + }); + + expect(mockInitiate).toHaveBeenCalledTimes(1); + expect(mockInitiate.mock.calls[0][0].payload.joinProductionOptions).toEqual( + expect.objectContaining({ + productionId: "prod-1", + lineId: "line-1", + username: "Alice", + videoinput: "cam-1", + videoEnabled: true, + }) + ); + }); +}); diff --git a/src/components/calls-page/video-line-join-card.tsx b/src/components/calls-page/video-line-join-card.tsx new file mode 100644 index 00000000..1e9f94ed --- /dev/null +++ b/src/components/calls-page/video-line-join-card.tsx @@ -0,0 +1,177 @@ +import styled from "@emotion/styled"; +import { useState } from "react"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; +import { useVideoDeviceLabels } from "../../hooks/use-video-device-labels"; +import { FormSelect, PrimaryButton } from "../form-elements/form-elements"; +import { FormItem } from "../user-settings-form/form-item"; + +const CardContainer = styled.div<{ order?: number }>` + padding: 2rem; + border: 0.1rem solid rgba(109, 109, 109, 0.3); + border-radius: 1rem; + background: rgba(45, 62, 124, 0.2); + flex: 0 0 calc(25% - 2rem); + flex-grow: 0; + min-width: 30rem; + order: ${({ order }) => order ?? 0}; + + @media (max-width: 1440px) { + flex: 0 0 calc(33.333% - 2rem); + } + + @media (max-width: 1024px) { + flex: 0 0 calc(50% - 2rem); + } + + @media (max-width: 768px) { + flex: 0 0 calc(100%); + min-width: 0; + } +`; + +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 2rem; +`; + +const CardTitleWrapper = styled.div` + flex: 1; + min-width: 0; +`; + +const CardTitle = styled.div` + font-size: 2rem; + font-weight: bold; + line-height: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +`; + +const CardSubtitle = styled.div` + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.55); + margin-top: 0.4rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const ButtonRow = styled.div` + margin: 2rem 0 0 0; + display: flex; + justify-content: flex-end; +`; + +const JoinBtn = styled(PrimaryButton)` + padding: 0.6rem 1.4rem; + font-size: 1.4rem; +`; + +type VideoLineJoinCardProps = { + productionId: string; + lineId: string; + lineName?: string; + productionName?: string; + presetOrder?: number; + customGlobalMute: string; + onJoined: () => void; +}; + +export const VideoLineJoinCard = ({ + productionId, + lineId, + lineName, + productionName, + presetOrder, + customGlobalMute, + onJoined, +}: VideoLineJoinCardProps) => { + const [videoinput, setVideoinput] = useState("no-device"); + const [{ userSettings, devices }, dispatch] = useGlobalState(); + const { initiateProductionCall } = useInitiateProductionCall({ dispatch }); + + useVideoDeviceLabels({ enabled: true, dispatch }); + + const displayName = lineName || lineId; + const hasCameraSelected = videoinput !== "no-device"; + + const handleJoin = async () => { + if (!userSettings?.username) return; + + if (hasCameraSelected) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: videoinput === "" ? true : { deviceId: { exact: videoinput } }, + }); + stream.getTracks().forEach((t) => t.stop()); + } catch { + // permission prompt may be denied — safe to ignore + } + } + + const success = await initiateProductionCall({ + payload: { + joinProductionOptions: { + productionId, + lineId, + username: userSettings.username, + audioinput: userSettings.audioinput, + videoinput, + lineUsedForProgramOutput: false, + isProgramUser: false, + videoEnabled: true, + lineName, + }, + audiooutput: userSettings.audiooutput, + }, + customGlobalMute, + }); + + if (success) { + onJoined(); + } + }; + + return ( + + + + {displayName} + {productionName && ( + {productionName} + )} + + + + setVideoinput(e.target.value)} + > + + {devices.videoInput && devices.videoInput.length > 0 ? ( + devices.videoInput.map((device, index) => ( + + )) + ) : ( + + )} + + + + + Join + + + + ); +}; diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx index 0b491355..34e3a75e 100644 --- a/src/components/checkbox/checkbox.tsx +++ b/src/components/checkbox/checkbox.tsx @@ -3,12 +3,15 @@ import styled from "@emotion/styled"; type TCheckboxProps = { label: string; checked?: boolean; + disabled?: boolean; onChange: (e: React.ChangeEvent) => void; }; -const CheckboxWrapper = styled.div` +const CheckboxWrapper = styled.label<{ disabled?: boolean }>` display: flex; align-items: center; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; + user-select: none; `; const CheckboxComponent = styled.input` @@ -41,21 +44,38 @@ const CheckboxComponent = styled.input` font-weight: bold; } - &:hover { + &:focus-visible { + outline: 0.2rem solid rgba(89, 203, 232, 1); + outline-offset: 0.2rem; + } + + &:hover:not(:disabled) { background-color: rgba(89, 203, 232, 1); } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } `; -export const Checkbox = ({ label, checked, onChange }: TCheckboxProps) => { - return ( - - - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - - ); -}; +const LabelText = styled.span<{ disabled?: boolean }>` + ${({ disabled }) => (disabled ? "opacity: 0.45; cursor: not-allowed;" : "")} +`; + +export const Checkbox = ({ + label, + checked, + disabled, + onChange, +}: TCheckboxProps) => ( + + + {label} + +); diff --git a/src/components/create-production/create-production-components.ts b/src/components/create-production/create-production-components.ts index 15a50bf5..5898c561 100644 --- a/src/components/create-production/create-production-components.ts +++ b/src/components/create-production/create-production-components.ts @@ -1,6 +1,6 @@ import styled from "@emotion/styled"; import { darkText, errorColour } from "../../css-helpers/defaults"; -import { FlexContainer } from "../generic-components"; +import { FlexContainer, mediaQueries } from "../generic-components"; export const HeaderWrapper = styled.div` display: flex; @@ -77,12 +77,13 @@ export const LineNumber = styled.span` export const LineInputRow = styled.div` display: flex; + flex-wrap: wrap; align-items: center; gap: 1rem; > *:first-of-type { - flex: 1; - min-width: 0; + flex: 1 1 14rem; + min-width: 14rem; } label { @@ -93,6 +94,17 @@ export const LineInputRow = styled.div` select { margin-bottom: 0; } + + ${mediaQueries.isSmallScreen} { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + + > *:first-of-type { + flex: 0 1 auto; + min-width: 0; + } + } `; export const TooltipWrapper = styled.span` diff --git a/src/components/create-production/create-production-page.tsx b/src/components/create-production/create-production-page.tsx index 02f9bb45..4cde573a 100644 --- a/src/components/create-production/create-production-page.tsx +++ b/src/components/create-production/create-production-page.tsx @@ -158,6 +158,7 @@ export const CreateProductionPage = () => { productionName: "", defaultLine: "", defaultLineProgramOutput: false, + defaultLineVideoEnabled: false, lines: [], }, }); @@ -466,6 +467,25 @@ export const CreateProductionPage = () => { )} /> + ( + + ) => + field.onChange(e.target.checked) + } + /> + + When enabled, participants can optionally share their camera + in this line. + + + )} + /> { )} /> + ( + + ) => + controllerField.onChange(e.target.checked) + } + /> + + When enabled, participants can optionally share their camera + in this line. + + + )} + /> { return useRequest({ params: @@ -18,10 +23,16 @@ export const useAddProductionLine = ( productionId, name: line.name, programOutputLine: line.programOutputLine, + videoEnabled: line.videoEnabled, } : null, apiCall: async (p) => { - await API.addProductionLine(p.productionId, p.name, p.programOutputLine); + await API.addProductionLine( + p.productionId, + p.name, + p.programOutputLine, + p.videoEnabled + ); }, errorMessage: (p) => `Failed to add production line: ${p.name}`, }); diff --git a/src/components/production-line/admin-panel.test.tsx b/src/components/production-line/admin-panel.test.tsx index 0718836c..1628dd79 100644 --- a/src/components/production-line/admin-panel.test.tsx +++ b/src/components/production-line/admin-panel.test.tsx @@ -12,6 +12,7 @@ const other: TParticipant = { endpointId: "endpoint-other", isActive: true, isWhip: false, + hasVideo: false, }; const me: TParticipant = { @@ -20,6 +21,7 @@ const me: TParticipant = { endpointId: "endpoint-me", isActive: true, isWhip: false, + hasVideo: false, }; const whip: TParticipant = { @@ -28,6 +30,7 @@ const whip: TParticipant = { endpointId: "endpoint-whip", isActive: true, isWhip: true, + hasVideo: false, }; const inactive: TParticipant = { @@ -36,6 +39,7 @@ const inactive: TParticipant = { endpointId: "endpoint-inactive", isActive: false, isWhip: false, + hasVideo: false, }; const renderPanel = (overrides?: { participants?: TParticipant[] }) => { diff --git a/src/components/production-line/call-header.tsx b/src/components/production-line/call-header.tsx index 574c3ef7..8d19fd1d 100644 --- a/src/components/production-line/call-header.tsx +++ b/src/components/production-line/call-header.tsx @@ -5,6 +5,7 @@ import { ChevronUpIcon, ChevronDownIcon, TVIcon, + VideoOnIcon, WhipIcon, ShareIcon, } from "../../assets/icons/icon"; @@ -201,6 +202,11 @@ export const CallHeaderComponent = ({ )} + {!open && line?.videoEnabled && ( + + + + )} @@ -270,6 +276,12 @@ export const CallHeaderComponent = ({ Audio feed )} + {line?.videoEnabled && open && ( + + + Video enabled + + )} {shareModalOpen && ( ( + + + +); + +const Overlay = styled.div<{ visible: boolean }>` + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 5rem 2rem 2.5rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.65)); + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; + opacity: ${({ visible }) => (visible ? 1 : 0)}; + transition: opacity 0.3s; + pointer-events: ${({ visible }) => (visible ? "auto" : "none")}; + z-index: 10; +`; + +const ToggleButton = styled.button<{ isMuted: boolean }>` + width: 5.5rem; + height: 5.5rem; + border-radius: 50%; + background: ${({ isMuted }) => + isMuted ? "rgba(220, 50, 50, 0.85)" : "rgba(255, 255, 255, 0.18)"}; + border: 2px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + + svg { + fill: #e8eaed; + } +`; + +const ExitFullscreenButton = styled.button` + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + border: 2px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + color: #fff; + display: flex; + align-items: center; + justify-content: center; +`; + +type FullscreenControlsProps = { + visible: boolean; + hasMic: boolean; + isInputMuted: boolean; + hasCamera?: boolean; + isVideoMuted?: boolean; + onToggleMute: () => void; + onToggleVideo?: () => void; + onExitFullscreen: () => void; +}; + +export const FullscreenControls = ({ + visible, + hasMic, + isInputMuted, + hasCamera, + isVideoMuted, + onToggleMute, + onToggleVideo, + onExitFullscreen, +}: FullscreenControlsProps) => ( + + {hasMic && ( + + {isInputMuted ? : } + + )} + {hasCamera && onToggleVideo && ( + + {isVideoMuted ? : } + + )} + + + + +); diff --git a/src/components/production-line/fullscreen-overlay-utils.test.ts b/src/components/production-line/fullscreen-overlay-utils.test.ts new file mode 100644 index 00000000..1d1b032b --- /dev/null +++ b/src/components/production-line/fullscreen-overlay-utils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { isRemoteVideoTile } from "./fullscreen-overlay-utils"; + +const makeRemoteTile = (): HTMLElement => { + const container = document.createElement("div"); + const label = document.createElement("span"); + label.dataset.videoTileLabel = "true"; + container.appendChild(label); + return container; +}; + +describe("isRemoteVideoTile", () => { + it("returns false for null / non-element values", () => { + expect(isRemoteVideoTile(null, null)).toBe(false); + expect(isRemoteVideoTile(undefined, null)).toBe(false); + }); + + it("returns false for the self-preview tile (no video-tile-label child)", () => { + const selfTile = document.createElement("div"); + selfTile.appendChild(document.createElement("video")); + expect(isRemoteVideoTile(selfTile, null)).toBe(false); + }); + + it("returns true for a remote tile container with a label child", () => { + expect(isRemoteVideoTile(makeRemoteTile(), null)).toBe(true); + }); + + it("returns true when the remote tile lives inside the provided grid", () => { + const grid = document.createElement("div"); + const tile = makeRemoteTile(); + grid.appendChild(tile); + expect(isRemoteVideoTile(tile, grid)).toBe(true); + }); + + it("returns false when the tile is NOT contained in the provided grid", () => { + const grid = document.createElement("div"); + const tile = makeRemoteTile(); // not appended to grid + expect(isRemoteVideoTile(tile, grid)).toBe(false); + }); +}); diff --git a/src/components/production-line/fullscreen-overlay-utils.ts b/src/components/production-line/fullscreen-overlay-utils.ts new file mode 100644 index 00000000..5619884d --- /dev/null +++ b/src/components/production-line/fullscreen-overlay-utils.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useOverlayReveal = (active: boolean) => { + const [showOverlay, setShowOverlay] = useState(false); + const hideTimerRef = useRef | undefined>( + undefined + ); + + const reveal = useCallback(() => { + setShowOverlay(true); + clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => setShowOverlay(false), 3000); + }, []); + + const handleMouseMove = useCallback(() => { + if (active) reveal(); + }, [active, reveal]); + + useEffect(() => () => clearTimeout(hideTimerRef.current), []); + + useEffect(() => { + if (!active) setShowOverlay(false); + }, [active]); + + return { showOverlay, reveal, handleMouseMove }; +}; + +export const isRemoteVideoTile = ( + el: Element | null | undefined, + gridEl: HTMLElement | null +): el is HTMLElement => { + if (!el || !(el instanceof HTMLElement)) return false; + if (!el.querySelector("[data-video-tile-label]")) return false; + if (gridEl && !gridEl.contains(el)) return false; + return true; +}; diff --git a/src/components/production-line/ice-gathering.test.ts b/src/components/production-line/ice-gathering.test.ts index 7113d2b2..9f18f9dd 100644 --- a/src/components/production-line/ice-gathering.test.ts +++ b/src/components/production-line/ice-gathering.test.ts @@ -43,7 +43,7 @@ describe("waitForIceGathering", () => { await expect(promise).resolves.toBeUndefined(); }); - it("rejects on timeout", async () => { + it("resolves on timeout (proceeds with partial candidates)", async () => { const pc = new MockRTCPeerConnection(); pc.iceGatheringState = "gathering"; @@ -54,7 +54,7 @@ describe("waitForIceGathering", () => { vi.advanceTimersByTime(3000); - await expect(promise).rejects.toThrow("ice gathering timeout"); + await expect(promise).resolves.toBeUndefined(); }); it("cleans up event listener after completion", async () => { @@ -74,18 +74,24 @@ describe("waitForIceGathering", () => { ); }); - it("uses default 5-second timeout", async () => { + it("uses default 8-second timeout", async () => { const pc = new MockRTCPeerConnection(); pc.iceGatheringState = "gathering"; - const promise = waitForIceGathering(pc as unknown as RTCPeerConnection); + let settled = false; + const promise = waitForIceGathering( + pc as unknown as RTCPeerConnection + ).then(() => { + settled = true; + }); - // At 4.9s, should not have rejected yet - vi.advanceTimersByTime(4900); + vi.advanceTimersByTime(7900); + // micro-task to let any prematurely-resolved promise settle + await Promise.resolve(); + expect(settled).toBe(false); - // At 5s, should reject vi.advanceTimersByTime(100); - - await expect(promise).rejects.toThrow("waited 5 seconds"); + await promise; + expect(settled).toBe(true); }); }); diff --git a/src/components/production-line/ice-gathering.ts b/src/components/production-line/ice-gathering.ts index 95ccd54b..47b2ce7c 100644 --- a/src/components/production-line/ice-gathering.ts +++ b/src/components/production-line/ice-gathering.ts @@ -1,15 +1,15 @@ /** - * Promisified ICE gathering state change listener. - * Extracted from use-rtc-connection.ts lines 208-230. + * Wait for ICE gathering to complete, or resolve with whatever candidates + * have been gathered so far once the timeout elapses. */ -const DEFAULT_ICE_TIMEOUT_MS = 5000; +const DEFAULT_ICE_TIMEOUT_MS = 8000; export const waitForIceGathering = ( rtcPeerConnection: RTCPeerConnection, timeoutMs: number = DEFAULT_ICE_TIMEOUT_MS ): Promise => - new Promise((resolve, reject) => { + new Promise((resolve) => { if (rtcPeerConnection.iceGatheringState === "complete") { resolve(); return; @@ -20,20 +20,14 @@ export const waitForIceGathering = ( const cb = () => { if (rtcPeerConnection.iceGatheringState === "complete") { rtcPeerConnection.removeEventListener("icegatheringstatechange", cb); - - if (timeout !== null) { - window.clearTimeout(timeout); - } - + if (timeout !== null) window.clearTimeout(timeout); resolve(); } }; timeout = window.setTimeout(() => { rtcPeerConnection.removeEventListener("icegatheringstatechange", cb); - reject( - new Error(`ice gathering timeout (waited ${timeoutMs / 1000} seconds)`) - ); + resolve(); }, timeoutMs); rtcPeerConnection.addEventListener("icegatheringstatechange", cb); diff --git a/src/components/production-line/match-video-tile-labels.test.ts b/src/components/production-line/match-video-tile-labels.test.ts new file mode 100644 index 00000000..a0a6575c --- /dev/null +++ b/src/components/production-line/match-video-tile-labels.test.ts @@ -0,0 +1,457 @@ +import { describe, it, expect } from "vitest"; +import { + computeVideoTileLabels, + LabelResult, + ParticipantInfo, + TileEndpointInfo, +} from "./match-video-tile-labels.ts"; + +// Test factories — keep tests readable. participant() defaults to a +// remote, active, non-WHIP user with video so each test only specifies +// what differs. +const participant = ( + overrides: Partial & { sessionId: string } +): ParticipantInfo => ({ + name: `user-${overrides.sessionId}`, + endpointId: `ep-${overrides.sessionId}`, + isActive: true, + isWhip: false, + hasVideo: true, + ...overrides, +}); + +const tile = ( + endpointId: string, + previousSessionId: string | null = null +): TileEndpointInfo => ({ endpointId, previousSessionId }); + +// Convenience: build the LabelResult for a tile that matched a given +// participant. Saves repeating { name, isWhip, sessionId } at every call. +const matched = (p: ParticipantInfo): LabelResult => ({ + name: p.name, + isWhip: p.isWhip, + sessionId: p.sessionId, +}); + +const blank: LabelResult = { name: null, isWhip: false, sessionId: null }; + +const SELF = "self-session"; + +describe("computeVideoTileLabels", () => { + it("returns no labels when there are no tiles", () => { + const out = computeVideoTileLabels( + [], + [participant({ sessionId: "a" })], + SELF + ); + expect(out).toEqual([]); + }); + + describe("phase 1: precise endpointId match", () => { + it("labels a tile whose endpointId matches a participant", () => { + const p = participant({ sessionId: "a" }); + const out = computeVideoTileLabels([tile(p.endpointId)], [p], SELF); + expect(out).toEqual([matched(p)]); + }); + + it("labels each of multiple tiles that all match precisely", () => { + const a = participant({ sessionId: "a" }); + const b = participant({ sessionId: "b", isWhip: true }); + const c = participant({ sessionId: "c" }); + const out = computeVideoTileLabels( + [tile(a.endpointId), tile(b.endpointId), tile(c.endpointId)], + [a, b, c], + SELF + ); + expect(out).toEqual([matched(a), matched(b), matched(c)]); + }); + + it("preserves input tile order in the output", () => { + const a = participant({ sessionId: "a", name: "Alice" }); + const b = participant({ sessionId: "b", name: "Bob" }); + const out = computeVideoTileLabels( + [tile(b.endpointId), tile(a.endpointId)], + [a, b], + SELF + ); + expect(out).toEqual([matched(b), matched(a)]); + }); + + it("matches a participant even when they are inactive (precise wins)", () => { + // The precise endpointId match doesn't care about active state — if + // the wire-level identity matches, we know who it is. + const inactive = participant({ sessionId: "a", isActive: false }); + const out = computeVideoTileLabels( + [tile(inactive.endpointId)], + [inactive], + SELF + ); + expect(out).toEqual([matched(inactive)]); + }); + + it("matches a tile whose endpoint belongs to self", () => { + // Self exclusion only applies to the phase-2 fallback. If a tile + // happens to carry a wire id that matches self (unusual but + // possible), labeling it is still correct. + const me = participant({ sessionId: SELF, name: "Me" }); + const out = computeVideoTileLabels([tile(me.endpointId)], [me], SELF); + expect(out).toEqual([matched(me)]); + }); + + it("gives the WHIP styling flag when matching a WHIP participant", () => { + const w = participant({ sessionId: "w", isWhip: true }); + const out = computeVideoTileLabels([tile(w.endpointId)], [w], SELF); + expect(out).toEqual([matched(w)]); + }); + + it("reports the matched participant's sessionId on the tile result", () => { + // sessionId on the result is what callers use to decide pin-button + // visibility in the user list (only participants with a tile get a + // pin button). + const p = participant({ sessionId: "a" }); + const [result] = computeVideoTileLabels([tile(p.endpointId)], [p], SELF); + expect(result.sessionId).toBe(p.sessionId); + }); + }); + + describe("phase 2: single-leftover fallback", () => { + it("labels a single unmatched tile when exactly one remote active participant is left", () => { + // Common case: one browser cam matched, one WHIP tile unmatched + // (msid stripped by ssrc-rewrite), and exactly one WHIP participant + // in the list. + const cam = participant({ sessionId: "cam" }); + const whip = participant({ sessionId: "whip", isWhip: true }); + const out = computeVideoTileLabels( + [tile(cam.endpointId), tile("opaque-uuid-from-smb")], + [cam, whip], + SELF + ); + expect(out).toEqual([matched(cam), matched(whip)]); + }); + + it("labels a single tile with empty endpointId when one WHIP participant is left", () => { + // Defensive: tile without dataset (empty endpointId) still falls + // through to the unambiguous-pair rule, provided the only + // remaining candidate is WHIP-flagged. + const onlyWhip = participant({ sessionId: "only", isWhip: true }); + const out = computeVideoTileLabels([tile("")], [onlyWhip], SELF); + expect(out).toEqual([matched(onlyWhip)]); + }); + + it("does NOT fall back to a non-publishing participant", () => { + // The fallback pool is restricted to active hasVideo participants — + // audio-only joiners and other non-publishers must not be guessed + // at, even if they're the only unmatched leftover. + const audioOnly = participant({ sessionId: "audio", hasVideo: false }); + const out = computeVideoTileLabels([tile("opaque")], [audioOnly], SELF); + expect(out).toEqual([blank]); + }); + + it("populates sessionId on the fallback-matched tile", () => { + // The pin-button-visibility logic depends on this being set even + // for the phase-2 fallback path (otherwise the WHIP tile, which + // only ever matches via fallback, would lose its pin button). + const whip = participant({ sessionId: "whip", isWhip: true }); + const [result] = computeVideoTileLabels([tile("opaque")], [whip], SELF); + expect(result.sessionId).toBe(whip.sessionId); + }); + }); + + describe("audio-only joiners (the regression fix)", () => { + it("keeps the WHIP label when an audio-only third user joins", () => { + // Reproduces the reported bug: operator + WHIP works fine, then a + // third user joins audio-only. They appear in the participant list + // but produce no tile. With the previous loose filter this caused + // 1 unmatched tile + 2 unmatched participants → ambiguous → WHIP + // label dropped. Restricting to isWhip-flagged candidates keeps + // the label. + const whip = participant({ + sessionId: "whip", + name: "Field Camera", + isWhip: true, + }); + const audioOnly = participant({ + sessionId: "audio-only", + isWhip: false, + hasVideo: false, + }); + const out = computeVideoTileLabels( + [tile("opaque")], + [whip, audioOnly], + SELF + ); + expect(out).toEqual([matched(whip)]); + }); + + it("keeps the WHIP label even with multiple audio-only joiners", () => { + const whip = participant({ + sessionId: "whip", + name: "WHIP Source", + isWhip: true, + }); + const audio1 = participant({ + sessionId: "a1", + isWhip: false, + hasVideo: false, + }); + const audio2 = participant({ + sessionId: "a2", + isWhip: false, + hasVideo: false, + }); + const audio3 = participant({ + sessionId: "a3", + isWhip: false, + hasVideo: false, + }); + const out = computeVideoTileLabels( + [tile("opaque")], + [whip, audio1, audio2, audio3], + SELF + ); + expect(out).toEqual([matched(whip)]); + }); + + it("labels precise-matched browser cams while audio-only joiners are present, plus the WHIP fallback", () => { + // Realistic mid-call composition: 1 browser cam (precise match), + // 1 WHIP (fallback), and 2 audio-only listeners (ignored via + // hasVideo:false). + const cam = participant({ sessionId: "cam", name: "Cam User" }); + const whip = participant({ + sessionId: "whip", + name: "WHIP", + isWhip: true, + }); + const audio1 = participant({ sessionId: "a1", hasVideo: false }); + const audio2 = participant({ sessionId: "a2", hasVideo: false }); + const out = computeVideoTileLabels( + [tile(cam.endpointId), tile("opaque")], + [cam, whip, audio1, audio2, participant({ sessionId: SELF })], + SELF + ); + expect(out).toEqual([matched(cam), matched(whip)]); + }); + + it("does NOT report a sessionId for audio-only joiners (no pin button)", () => { + // Used to gate the user-list pin button — only participants with a + // sessionId reported on a tile result deserve a pin button. + const whip = participant({ sessionId: "whip", isWhip: true }); + const audioOnly = participant({ + sessionId: "audio-only", + hasVideo: false, + }); + const out = computeVideoTileLabels( + [tile("opaque")], + [whip, audioOnly], + SELF + ); + const reportedSessionIds = out + .map((r) => r.sessionId) + .filter((sid): sid is string => sid !== null); + expect(reportedSessionIds).toEqual([whip.sessionId]); + expect(reportedSessionIds).not.toContain(audioOnly.sessionId); + }); + }); + + describe("safety: never label when ambiguous", () => { + it("leaves all leftover tiles blank when 2 tiles + 2 participants are unmatched", () => { + // Two-of-each ambiguity. Either tile could belong to either + // participant; neither labeling is provably correct. + const a = participant({ sessionId: "a" }); + const b = participant({ sessionId: "b" }); + const out = computeVideoTileLabels( + [tile("opaque-1"), tile("opaque-2")], + [a, b], + SELF + ); + expect(out).toEqual([blank, blank]); + }); + + it("leaves a tile blank when there are no unmatched participants", () => { + // 1 unmatched tile, 0 unmatched participants — nothing legitimate + // to pair with. + const m = participant({ sessionId: "matched" }); + const out = computeVideoTileLabels( + [tile(m.endpointId), tile("orphan-tile")], + [m], + SELF + ); + expect(out).toEqual([matched(m), blank]); + }); + + it("does not label leftover tiles when there are 0 of them but unmatched participants exist", () => { + // Mirror image: nothing to label. + const a = participant({ sessionId: "a" }); + const out = computeVideoTileLabels([tile(a.endpointId)], [a], SELF); + expect(out).toEqual([matched(a)]); + }); + + it("excludes self from the unmatched-participant pool in phase 2", () => { + // Self has a phantom unmatched session in the list; the tile must + // NOT be labeled with the operator's own name. + const me = participant({ sessionId: SELF, name: "Me" }); + const out = computeVideoTileLabels([tile("opaque")], [me], SELF); + expect(out).toEqual([blank]); + }); + + it("excludes inactive participants from the unmatched pool in phase 2", () => { + // An inactive ghost session shouldn't be eligible for fallback + // pairing — they're not actually publishing. + const ghost = participant({ sessionId: "ghost", isActive: false }); + const out = computeVideoTileLabels([tile("opaque")], [ghost], SELF); + expect(out).toEqual([blank]); + }); + + it("excludes participants already matched in phase 1 from the unmatched pool", () => { + // a is matched precisely; the ambiguous leftover (1 tile + 1 + // unmatched remaining = b) should still pair safely. + const a = participant({ sessionId: "a" }); + const b = participant({ sessionId: "b", isWhip: true }); + const out = computeVideoTileLabels( + [tile(a.endpointId), tile("opaque")], + [a, b], + SELF + ); + expect(out).toEqual([matched(a), matched(b)]); + }); + + it("does not label any leftover tile when 1 tile + 2 unmatched participants", () => { + // Asymmetric ambiguity — tile could belong to either of two + // candidates. Refuse to guess. + const a = participant({ sessionId: "a" }); + const b = participant({ sessionId: "b" }); + const out = computeVideoTileLabels([tile("opaque")], [a, b], SELF); + expect(out).toEqual([blank]); + }); + + it("does not label any leftover tile when 2 tiles + 1 unmatched participant", () => { + // Mirror: one participant could match either of two tiles. + const a = participant({ sessionId: "a" }); + const out = computeVideoTileLabels( + [tile("opaque-1"), tile("opaque-2")], + [a], + SELF + ); + expect(out).toEqual([blank, blank]); + }); + }); + + describe("realistic mixed scenarios", () => { + it("labels precise matches and the single fallback together", () => { + // 2 browser cams matched precisely + 1 WHIP tile via fallback. + const cam1 = participant({ sessionId: "cam1", name: "Cam 1" }); + const cam2 = participant({ sessionId: "cam2", name: "Cam 2" }); + const whip = participant({ + sessionId: "whip", + name: "Field Camera", + isWhip: true, + }); + const out = computeVideoTileLabels( + [tile(cam1.endpointId), tile("opaque"), tile(cam2.endpointId)], + [cam1, cam2, whip, participant({ sessionId: SELF, name: "Self" })], + SELF + ); + expect(out).toEqual([matched(cam1), matched(whip), matched(cam2)]); + }); + + it("falls back safely when the participant list lags behind a new joiner", () => { + // Backend hasn't yet exposed the joiner in /line polling. Tile + // arrives with an unrecognized endpointId. With no other unmatched + // participants we leave it blank rather than guessing. + const cam = participant({ sessionId: "cam" }); + const out = computeVideoTileLabels( + [tile(cam.endpointId), tile("brand-new-id")], + [cam], + SELF + ); + expect(out).toEqual([matched(cam), blank]); + }); + }); + + describe("previously-bound tiles (orphan, not Phase 2 candidate)", () => { + it("does not relabel a tile bound to a now-departed publisher when a new publisher joins", () => { + // Reproduces the WHIP-after-leave bug: Sandra and Elsa were both + // publishing; Sandra leaves, polling reflects her absence, then a + // WHIP source joins. The polling response can update participants + // before the WHIP track event arrives. Without this guard, Phase 2 + // would pair Sandra's still-existing tile (1 unmatched) with WHIP + // (1 leftover hasVideo) and the user would see Sandra's frozen + // last frame under WHIP's name tag. + const elsa = participant({ sessionId: "elsa", name: "Elsa" }); + const whip = participant({ + sessionId: "whip", + name: "Field Camera", + isWhip: true, + }); + const sandraEndpoint = "ep-sandra"; + const out = computeVideoTileLabels( + [tile(elsa.endpointId), tile(sandraEndpoint, "sandra")], + [elsa, whip], + SELF + ); + expect(out).toEqual([matched(elsa), blank]); + }); + + it("still allows Phase 1 to resurrect a bound tile when its publisher returns", () => { + // The previousSessionId guard only blocks Phase 2 fallback; an + // exact endpointId match (Phase 1) is unambiguous and should win + // even on a previously-bound tile. + const cam = participant({ sessionId: "cam", name: "Cam" }); + const out = computeVideoTileLabels( + [tile(cam.endpointId, cam.sessionId)], + [cam], + SELF + ); + expect(out).toEqual([matched(cam)]); + }); + + it("stably rebinds a bound tile to the same session when that session is still alive", () => { + const whip = participant({ + sessionId: "whip", + name: "Field Camera", + isWhip: true, + }); + const out = computeVideoTileLabels( + [tile("opaque-old", "whip")], + [whip], + SELF + ); + expect(out).toEqual([matched(whip)]); + }); + + it("does NOT rebind a bound tile when its session has departed", () => { + const departed = "departed-session"; + const someone = participant({ sessionId: "someone-else" }); + const out = computeVideoTileLabels( + [tile("opaque-old", departed), tile(someone.endpointId)], + [someone], + SELF + ); + expect(out).toEqual([blank, matched(someone)]); + }); + + it("does NOT rebind to a session that another tile already claimed in Phase 1", () => { + const cam = participant({ sessionId: "cam", name: "Cam" }); + const out = computeVideoTileLabels( + [tile("opaque-old", "cam"), tile(cam.endpointId)], + [cam], + SELF + ); + expect(out).toEqual([blank, matched(cam)]); + }); + + it("still labels a fresh unmatched tile via Phase 2 when an orphan tile sits alongside it", () => { + const whip = participant({ + sessionId: "whip", + name: "WHIP", + isWhip: true, + }); + const out = computeVideoTileLabels( + [tile("orphan-opaque", "departed"), tile("fresh-opaque")], + [whip], + SELF + ); + expect(out).toEqual([blank, matched(whip)]); + }); + }); +}); diff --git a/src/components/production-line/match-video-tile-labels.ts b/src/components/production-line/match-video-tile-labels.ts new file mode 100644 index 00000000..7d7d814b --- /dev/null +++ b/src/components/production-line/match-video-tile-labels.ts @@ -0,0 +1,95 @@ +export type TileEndpointInfo = { + endpointId: string; + previousSessionId: string | null; +}; + +export type ParticipantInfo = { + name: string; + sessionId: string; + endpointId: string; + isActive: boolean; + isWhip: boolean; + hasVideo?: boolean; +}; + +export type LabelResult = { + name: string | null; + isWhip: boolean; + sessionId: string | null; +}; + +const blank = (): LabelResult => ({ + name: null, + isWhip: false, + sessionId: null, +}); + +export const computeVideoTileLabels = ( + tiles: TileEndpointInfo[], + participants: ParticipantInfo[], + selfSessionId: string | null +): LabelResult[] => { + const results: LabelResult[] = tiles.map(blank); + + // Phase 1 — precise endpointId match. + const matchedTileIndices = new Set(); + const matchedSessionIds = new Set(); + tiles.forEach((tile, i) => { + if (!tile.endpointId) return; + const participant = participants.find( + (p) => p.endpointId === tile.endpointId + ); + if (participant) { + results[i] = { + name: participant.name, + isWhip: participant.isWhip, + sessionId: participant.sessionId, + }; + matchedTileIndices.add(i); + matchedSessionIds.add(participant.sessionId); + } + }); + + tiles.forEach((tile, i) => { + if (matchedTileIndices.has(i)) return; + if (tile.previousSessionId === null) return; + const p = participants.find( + (q) => q.sessionId === tile.previousSessionId && q.isActive && q.hasVideo + ); + if (!p) return; + if (matchedSessionIds.has(p.sessionId)) return; + results[i] = { + name: p.name, + isWhip: p.isWhip, + sessionId: p.sessionId, + }; + matchedTileIndices.add(i); + matchedSessionIds.add(p.sessionId); + }); + + const remainingTileIndices: number[] = []; + tiles.forEach((tile, i) => { + if (matchedTileIndices.has(i)) return; + if (tile.previousSessionId !== null) return; + remainingTileIndices.push(i); + }); + const remainingPublishers = participants.filter( + (p) => + p.hasVideo && + p.sessionId !== selfSessionId && + p.isActive && + !matchedSessionIds.has(p.sessionId) + ); + + if (remainingTileIndices.length === 1 && remainingPublishers.length === 1) { + const [idx] = remainingTileIndices; + const [p] = remainingPublishers; + results[idx] = { + name: p.name, + isWhip: p.isWhip, + sessionId: p.sessionId, + }; + } + + return results; +}; diff --git a/src/components/production-line/pin-data-channel.test.ts b/src/components/production-line/pin-data-channel.test.ts new file mode 100644 index 00000000..a668526e --- /dev/null +++ b/src/components/production-line/pin-data-channel.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import { sendPinnedEndpoint } from "./pin-data-channel.ts"; + +const makeChannel = (readyState: RTCDataChannelState = "open") => + ({ readyState, send: vi.fn() }) as unknown as RTCDataChannel & { + send: ReturnType; + }; + +describe("sendPinnedEndpoint", () => { + it("sends PinnedEndpointsChanged with the endpoint id", () => { + const channel = makeChannel(); + + expect(sendPinnedEndpoint(channel, "endpoint-1")).toBe(true); + expect(JSON.parse(channel.send.mock.calls[0][0])).toEqual({ + type: "PinnedEndpointsChanged", + pinnedEndpoints: ["endpoint-1"], + }); + }); + + it("omits pinnedEndpoints to clear the pin", () => { + const channel = makeChannel(); + + expect(sendPinnedEndpoint(channel, null)).toBe(true); + expect(JSON.parse(channel.send.mock.calls[0][0])).toEqual({ + type: "PinnedEndpointsChanged", + }); + }); + + it("returns false without a channel", () => { + expect(sendPinnedEndpoint(null, "endpoint-1")).toBe(false); + }); + + it.each(["connecting", "closing", "closed"] as const)( + "returns false and does not send when the channel is %s", + (state) => { + const channel = makeChannel(state); + + expect(sendPinnedEndpoint(channel, "endpoint-1")).toBe(false); + expect(channel.send).not.toHaveBeenCalled(); + } + ); + + it("returns false when send throws", () => { + const channel = makeChannel(); + channel.send.mockImplementation(() => { + throw new Error("InvalidStateError"); + }); + + expect(sendPinnedEndpoint(channel, "endpoint-1")).toBe(false); + }); +}); diff --git a/src/components/production-line/pin-data-channel.ts b/src/components/production-line/pin-data-channel.ts new file mode 100644 index 00000000..8d667190 --- /dev/null +++ b/src/components/production-line/pin-data-channel.ts @@ -0,0 +1,18 @@ +// SMB "pinned video" selection, sent over the conference data channel. +export const sendPinnedEndpoint = ( + dataChannel: RTCDataChannel | null, + endpointId: string | null +): boolean => { + if (!dataChannel || dataChannel.readyState !== "open") return false; + + const message = endpointId + ? { type: "PinnedEndpointsChanged", pinnedEndpoints: [endpointId] } + : { type: "PinnedEndpointsChanged" }; + + try { + dataChannel.send(JSON.stringify(message)); + return true; + } catch { + return false; + } +}; diff --git a/src/components/production-line/pinnable-participants.test.ts b/src/components/production-line/pinnable-participants.test.ts new file mode 100644 index 00000000..cd129765 --- /dev/null +++ b/src/components/production-line/pinnable-participants.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { + getPinnableParticipants, + hasRemoteVideoPublisher, +} from "./pinnable-participants.ts"; +import { TParticipant } from "./types.ts"; + +const participant = (overrides: Partial = {}): TParticipant => ({ + name: "user", + sessionId: "a", + endpointId: "ep-a", + isActive: true, + isWhip: false, + isWhepReceiver: false, + hasVideo: true, + ...overrides, +}); + +describe("getPinnableParticipants", () => { + it("keeps active remote participants with video", () => { + const a = participant({ sessionId: "a" }); + expect(getPinnableParticipants([a], "self")).toEqual([a]); + }); + + it("excludes self, inactive, video-less and WHEP receivers", () => { + const list = [ + participant({ sessionId: "self" }), + participant({ sessionId: "b", isActive: false }), + participant({ sessionId: "c", hasVideo: false }), + participant({ sessionId: "d", isWhepReceiver: true }), + ]; + expect(getPinnableParticipants(list, "self")).toEqual([]); + }); +}); + +describe("hasRemoteVideoPublisher", () => { + it("is true while a remote participant is publishing video", () => { + expect( + hasRemoteVideoPublisher([participant({ sessionId: "a" })], "self") + ).toBe(true); + }); + + it("is false once the last publisher leaves, ignoring self and receivers", () => { + const self = participant({ sessionId: "self" }); + const receiver = participant({ sessionId: "r", isWhepReceiver: true }); + const gone = participant({ sessionId: "a", isActive: false }); + + expect(hasRemoteVideoPublisher([self, receiver, gone], "self")).toBe(false); + expect(hasRemoteVideoPublisher([], "self")).toBe(false); + }); +}); diff --git a/src/components/production-line/pinnable-participants.ts b/src/components/production-line/pinnable-participants.ts new file mode 100644 index 00000000..eeac217a --- /dev/null +++ b/src/components/production-line/pinnable-participants.ts @@ -0,0 +1,18 @@ +import { TParticipant } from "./types.ts"; + +export const getPinnableParticipants = ( + participants: TParticipant[], + selfSessionId: string | null +): TParticipant[] => + participants.filter( + (p) => + p.sessionId !== selfSessionId && + p.isActive && + p.hasVideo && + !p.isWhepReceiver + ); + +export const hasRemoteVideoPublisher = ( + participants: TParticipant[], + selfSessionId: string | null +): boolean => getPinnableParticipants(participants, selfSessionId).length > 0; diff --git a/src/components/production-line/production-line-components.ts b/src/components/production-line/production-line-components.ts index f45c6766..3978dbb6 100644 --- a/src/components/production-line/production-line-components.ts +++ b/src/components/production-line/production-line-components.ts @@ -189,6 +189,7 @@ export const CallWrapper = styled.div<{ export const CallContainer = styled(CollapsibleItemWrapper)<{ isProgramLine?: boolean; + isVideoEnabled?: boolean; }>` margin: 0; width: 100%; @@ -200,8 +201,11 @@ export const CallContainer = styled(CollapsibleItemWrapper)<{ border: 0.1rem solid rgba(109, 109, 109, 0.3); overflow: hidden; - background: ${({ isProgramLine }) => - isProgramLine ? "rgba(73, 67, 124, 0.2)" : "rgba(50, 56, 59, 0.4)"}; + background: ${({ isProgramLine, isVideoEnabled }) => { + if (isVideoEnabled) return "rgba(45, 62, 124, 0.2)"; + if (isProgramLine) return "rgba(73, 67, 124, 0.2)"; + return "rgba(50, 56, 59, 0.4)"; + }}; `; export const CallHeader = styled(HeaderWrapper)` @@ -247,3 +251,89 @@ export const MinifiedControlsButton = styled(UserControlBtn)` } } `; + +type PinnedProps = { isPinned: boolean }; + +export const VideoContainer = styled.div` + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background-color: #000; + border-radius: 0.5rem; + overflow: hidden; + order: ${({ isPinned }) => (isPinned ? -1 : 0)}; +`; + +export const Video = styled.video<{ isVideoMuted: boolean }>` + width: 100%; + height: 100%; + object-fit: cover; + background-color: #000; + display: ${({ isVideoMuted }) => (isVideoMuted ? "none" : "block")}; +`; + +export const VideoOptionsButton = styled.button` + position: absolute; + top: 0.25rem; + left: 0.25rem; + width: 2.2rem; + height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + background: ${({ isPinned }) => + isPinned ? "rgba(89, 203, 232, 0.80)" : "rgba(0, 0, 0, 0.45)"}; + border: none; + border-radius: 0.25rem 0.25rem 0.5rem 0.25rem; + cursor: pointer; + padding: 0; + color: #d9d9d9; + + & svg { + fill: currentColor; + } +`; + +export const NameTag = styled.span` + position: absolute; + bottom: 0.25rem; + right: 0.25rem; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem 0.25rem 0.25rem 0.25rem; + font-size: 1.4rem; + font-weight: 600; + color: #1a1a1a; + background-color: rgba(123, 226, 123, 0.82); + max-width: calc(100% - 0.5rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; +`; + +export const PinDialogBackdrop = styled.div` + position: fixed; + inset: 0; + z-index: 200; +`; + +export const PinDialogPopover = styled.div<{ top: number; left: number }>` + position: fixed; + top: ${({ top }) => `${top}px`}; + left: ${({ left }) => `${left}px`}; + z-index: 201; +`; + +export const ListInnerWrapper = styled.div` + width: 100%; +`; + +export const VideoSection = styled.div` + width: 100%; +`; + +export const VideoGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.5rem; +`; diff --git a/src/components/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index 42f2abc8..039d78ed 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useNavigate, useParams } from "react-router"; import { isBrowserFirefox, isMobile, isTablet } from "../../bowser.ts"; @@ -26,15 +26,23 @@ import { CallContainer, CallWrapper, ConnectionErrorWrapper, + ListInnerWrapper, ListWrapper, LoaderWrapper, LongPressWrapper, + PinDialogBackdrop, + PinDialogPopover, + VideoGrid, + VideoSection, } from "./production-line-components.ts"; import { SelectDevices } from "./select-devices.tsx"; import { SymphonyRtcConnectionComponent } from "./symphony-rtc-connection-component.tsx"; import { useActiveParticipant } from "./use-active-participant.tsx"; import { useAudioCue } from "./use-audio-cue.ts"; import { useAudioInput } from "./use-audio-input.ts"; +import { useVideoInput } from "./use-video-input.ts"; +import { TJoinProductionOptions } from "./types.ts"; +import { useShadowRtcConnection } from "./use-shadow-rtc-connection.ts"; import { useCheckBadLineData } from "./use-check-bad-line-data.ts"; import { useIsLoading } from "./use-is-loading.ts"; import { useLineHotkeys, useSpeakerHotkeys } from "./use-line-hotkeys.ts"; @@ -47,6 +55,15 @@ import { ReconnectBanner } from "./reconnect-banner.tsx"; import { useVolumeReducer } from "./use-volume-reducer.tsx"; import { UserControls } from "./user-controls.tsx"; import { UserList } from "./user-list.tsx"; +import { computeVideoTileLabels } from "./match-video-tile-labels.ts"; +import { useVideoTileGrid } from "./use-video-tile-grid.ts"; +import { useVideoSourcePin } from "./use-video-source-pin.ts"; +import { sendPinnedEndpoint } from "./pin-data-channel.ts"; +import { hasRemoteVideoPublisher } from "./pinnable-participants.ts"; +import { SelfPreviewTile } from "./self-preview-tile.tsx"; +import { ReturnFeedTile } from "./return-feed-tile.tsx"; +import { RemoteFullscreenControls } from "./remote-fullscreen-controls.tsx"; +import { VideoOptionsDialogue } from "./video-options-dialogue.tsx"; import { AdminPanel, AdminPanelButton } from "./admin-panel.tsx"; type TProductionLine = { @@ -94,7 +111,20 @@ export const ProductionLine = ({ const [confirmModalOpen, setConfirmModalOpen] = useState(false); const [muteError, setMuteError] = useState(false); const [userId, setUserId] = useState(""); + const [isVideoMuted, setIsVideoMuted] = useState(false); + const [pinnedSessionId, setPinnedSessionId] = useState(null); + const desiredPinEndpointIdRef = useRef(null); + const [pinnedContainer, setPinnedContainer] = useState( + null + ); + const [userOptionsTarget, setUserOptionsTarget] = useState<{ + sessionId: string; + rect: DOMRect; + } | null>(null); const [userName, setUserName] = useState(""); + const [pendingWhepTargetSessionId, setPendingWhepTargetSessionId] = useState< + string | null + >(null); const [kickModalOpen, setKickModalOpen] = useState(false); const [kickError, setKickError] = useState(false); const [kickSessionId, setKickSessionId] = useState(""); @@ -109,6 +139,7 @@ export const ProductionLine = ({ audioLevelAboveThreshold, connectionState, audioElements, + videoElements, sessionId, hotkeys: savedHotkeys, dataChannel, @@ -124,6 +155,23 @@ export const ProductionLine = ({ dispatch, }); + const videoEnabled = joinProductionOptions?.videoEnabled ?? false; + const [inputVideoStream, , resetVideoInput] = useVideoInput({ + videoInputId: videoEnabled + ? (joinProductionOptions?.videoinput ?? "no-device") + : null, + callId: id, + dispatch, + }); + + useEffect(() => { + if (!inputVideoStream || inputVideoStream === "no-device") return; + inputVideoStream.getVideoTracks().forEach((track) => { + // eslint-disable-next-line no-param-reassign + track.enabled = !isVideoMuted; + }); + }, [inputVideoStream, isVideoMuted]); + useEffect(() => { if (audioInputError) { setConnectionActive(false); @@ -159,6 +207,69 @@ export const ProductionLine = ({ ); const isSelfDominantSpeaker = lineParticipant === dominantSpeaker; + + const tileMatches = useMemo( + () => + computeVideoTileLabels( + (videoElements ?? []).map((el) => ({ + endpointId: el.dataset.endpointId || "", + previousSessionId: el.dataset.lastSessionId || null, + })), + line?.participants ?? [], + callState.sessionId ?? null + ), + [videoElements, line?.participants, callState.sessionId] + ); + + const pinnedVideoSessionId = pinnedSessionId; + + const { pushWhepSourceToBackend, pushVideoSourceToBackend } = + useVideoSourcePin({ + joinProductionOptions: joinProductionOptions ?? null, + callSessionId: callState.sessionId ?? null, + }); + + useEffect(() => { + const selfSid = callState.sessionId; + if (!selfSid) return; + + const eligible = (line?.participants ?? []).filter( + (p) => + p.sessionId !== selfSid && p.isActive && p.hasVideo && !p.isWhepReceiver + ); + const pinnedStillEligible = + pinnedSessionId !== null && + eligible.some((p) => p.sessionId === pinnedSessionId); + if (pinnedStillEligible) return; + const next = eligible[0]?.sessionId ?? null; + if (next === pinnedSessionId) return; + setPinnedSessionId(next); + pushVideoSourceToBackend(next); + + const nextEndpointId = + eligible.find((p) => p.sessionId === next)?.endpointId ?? null; + desiredPinEndpointIdRef.current = nextEndpointId; + sendPinnedEndpoint(dataChannel, nextEndpointId); + }, [ + line?.participants, + callState.sessionId, + pinnedSessionId, + pushVideoSourceToBackend, + dataChannel, + ]); + + useEffect(() => { + if (!dataChannel) return undefined; + const flush = () => + sendPinnedEndpoint(dataChannel, desiredPinEndpointIdRef.current); + if (dataChannel.readyState === "open") { + flush(); + return undefined; + } + dataChannel.addEventListener("open", flush); + return () => dataChannel.removeEventListener("open", flush); + }, [dataChannel]); + const isWhipOnLine = line?.participants.some((p) => p.isWhip); const isSomeoneSpeaking = !isProgramOutputLine && @@ -172,6 +283,38 @@ export const ProductionLine = ({ : null ); + const siblingProgramLine = useMemo( + () => + production?.lines.find( + (l: { + programOutputLine?: boolean; + id: string; + name: string; + videoEnabled?: boolean; + }) => l.programOutputLine && l.id !== joinProductionOptions?.lineId + ) ?? null, + [production, joinProductionOptions?.lineId] + ); + + const shadowJoinOptions: TJoinProductionOptions | null = useMemo(() => { + if (!videoEnabled || !siblingProgramLine || !joinProductionOptions) + return null; + return { + ...joinProductionOptions, + lineId: siblingProgramLine.id, + lineName: siblingProgramLine.name, + videoinput: undefined, + videoEnabled: !!siblingProgramLine.videoEnabled, + isProgramUser: true, + lineUsedForProgramOutput: true, + }; + }, [videoEnabled, siblingProgramLine, joinProductionOptions]); + + const { + audioElements: shadowAudioElements, + videoElements: shadowVideoElements, + } = useShadowRtcConnection(shadowJoinOptions); + const { muteInput, isInputMuted } = useMuteInput({ inputAudioStream, isProgramOutputLine, @@ -236,6 +379,13 @@ export const ProductionLine = ({ } }, [audioElements]); + useEffect(() => { + shadowAudioElements.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.volume = value; + }); + }, [shadowAudioElements, value]); + const { startTalking, stopTalking, @@ -308,6 +458,7 @@ export const ProductionLine = ({ const exit = useCallback(() => { setConnectionActive(false); playExitSound(); + resetVideoInput(); dispatch({ type: "REMOVE_CALL", payload: { id }, @@ -317,7 +468,28 @@ export const ProductionLine = ({ if (isSingleCall) { navigate("/"); } - }, [dispatch, id, playExitSound, isSingleCall, navigate, deregisterCall]); + }, [ + dispatch, + id, + playExitSound, + resetVideoInput, + isSingleCall, + navigate, + deregisterCall, + ]); + + // Stop camera tracks on unmount so the hardware/indicator is released even + // when the component is torn down without going through the exit button. + const resetVideoInputRef = useRef(resetVideoInput); + useEffect(() => { + resetVideoInputRef.current = resetVideoInput; + }, [resetVideoInput]); + useEffect( + () => () => { + resetVideoInputRef.current(); + }, + [] + ); useEffect(() => { if (!isRemotelyDisconnected) return; @@ -374,6 +546,16 @@ export const ProductionLine = ({ setIsOutputMuted(!isOutputMuted); }, [audioElements, isOutputMuted]); + const toggleVideo = useCallback(() => { + if (!inputVideoStream || inputVideoStream === "no-device") return; + const nextMuted = !isVideoMuted; + inputVideoStream.getVideoTracks().forEach((track) => { + // eslint-disable-next-line no-param-reassign + track.enabled = !nextMuted; + }); + setIsVideoMuted(nextMuted); + }, [inputVideoStream, isVideoMuted]); + const setActionHandler = useCallback( (action: string, handler: () => void) => { const handlers = callActionHandlers.current; @@ -470,6 +652,196 @@ export const ProductionLine = ({ } }; + const handleAutoUnpin = useCallback(() => setPinnedContainer(null), []); + + const pendingPinPromiseRef = useRef | null>(null); + + const [videoGridNode, setVideoGridNode] = useState( + null + ); + + const { setVideoGridEl } = useVideoTileGrid({ + videoElements: videoElements ?? null, + participants: line?.participants ?? [], + tileMatches, + pinnedContainer, + pinnedSessionId, + selfSessionId: callState.sessionId ?? null, + cameraStream: + inputVideoStream && inputVideoStream !== "no-device" + ? inputVideoStream + : null, + cameraLabel: joinProductionOptions?.username ?? null, + pendingPinPromiseRef, + onAutoUnpin: handleAutoUnpin, + }); + + const siblingLine = useLinePolling({ + callId: id, + joinProductionOptions: shadowJoinOptions, + }); + + const whipParticipantName = useMemo( + () => siblingLine?.participants.find((p) => p.isWhip)?.name ?? null, + [siblingLine] + ); + + const pgmStream = useMemo(() => { + if (!videoEnabled || shadowVideoElements.length === 0) return null; + const el = shadowVideoElements[0]; + return el.srcObject instanceof MediaStream ? el.srcObject : null; + }, [videoEnabled, shadowVideoElements]); + + const hasRemoteVideo = useMemo( + () => + hasRemoteVideoPublisher( + line?.participants ?? [], + callState.sessionId ?? null + ), + [line?.participants, callState.sessionId] + ); + + const pinnedRemoteStream = useMemo(() => { + const elements = videoElements ?? []; + if (elements.length === 0 || !hasRemoteVideo) return null; + const matchByPin = pinnedSessionId + ? elements.find((el) => el.dataset.lastSessionId === pinnedSessionId) + : null; + const candidate = + matchByPin ?? (elements.length === 1 ? elements[0] : null); + return candidate?.srcObject instanceof MediaStream + ? candidate.srcObject + : null; + }, [videoElements, pinnedSessionId, hasRemoteVideo]); + + const pinnedRemoteName = useMemo(() => { + if (!hasRemoteVideo) return null; + if (pinnedSessionId) { + return ( + line?.participants.find((p) => p.sessionId === pinnedSessionId)?.name ?? + null + ); + } + const elements = videoElements ?? []; + const candidate = elements.length === 1 ? elements[0] : null; + const sid = candidate?.dataset.lastSessionId ?? null; + if (!sid) return null; + return line?.participants.find((p) => p.sessionId === sid)?.name ?? null; + }, [videoElements, pinnedSessionId, line?.participants, hasRemoteVideo]); + + const pinnedRemoteIsWhip = useMemo(() => { + if (!hasRemoteVideo) return null; + if (pinnedSessionId) { + return ( + line?.participants.find((p) => p.sessionId === pinnedSessionId) + ?.isWhip ?? null + ); + } + const elements = videoElements ?? []; + const candidate = elements.length === 1 ? elements[0] : null; + const sid = candidate?.dataset.lastSessionId ?? null; + if (!sid) return null; + return line?.participants.find((p) => p.sessionId === sid)?.isWhip ?? null; + }, [videoElements, pinnedSessionId, line?.participants, hasRemoteVideo]); + + const handleSetWhep = (targetSessionId: string) => { + setPendingWhepTargetSessionId(targetSessionId); + setUserOptionsTarget(null); + }; + + const confirmSetWhep = () => { + if (!pendingWhepTargetSessionId) return; + const isAlreadyWhep = + (line?.whepSourceSessionId ?? null) === pendingWhepTargetSessionId; + const next = isAlreadyWhep ? null : pendingWhepTargetSessionId; + pushWhepSourceToBackend(next); + setPendingWhepTargetSessionId(null); + }; + + const whepConfirmTexts = useMemo(() => { + if (!pendingWhepTargetSessionId) return null; + const targetParticipant = line?.participants.find( + (p) => p.sessionId === pendingWhepTargetSessionId + ); + const targetName = targetParticipant?.name ?? "this participant"; + const currentSid = line?.whepSourceSessionId ?? null; + const isToggleOff = currentSid === pendingWhepTargetSessionId; + const note = ( + <> + Note: if you already have a WHEP receiver running, you might need to + restart it to see the updated source. + + ); + if (isToggleOff) { + return { + title: "Clear WHEP source?", + description: ( + <> + Are you sure you want to clear {targetName} as the WHEP + source for everyone on this call? + + ), + confirmationText: note, + }; + } + const currentParticipant = currentSid + ? line?.participants.find((p) => p.sessionId === currentSid) + : null; + if (currentParticipant) { + return { + title: "Replace WHEP source?", + description: ( + <> + Are you sure you want to replace the current WHEP source{" "} + {currentParticipant.name} with {targetName} for + everyone on this call? + + ), + confirmationText: note, + }; + } + return { + title: "Set WHEP source?", + description: ( + <> + Are you sure you want to set {targetName} as the WHEP source + for everyone on this call? + + ), + confirmationText: note, + }; + }, [ + pendingWhepTargetSessionId, + line?.participants, + line?.whepSourceSessionId, + ]); + + const handlePin = (targetSessionId: string) => { + const isAlreadyPinned = pinnedSessionId === targetSessionId; + const next = isAlreadyPinned ? null : targetSessionId; + setPinnedSessionId(next); + let nextEndpointId: string | null = null; + if (next === null) { + setPinnedContainer(null); + } else { + const participant = line?.participants.find((p) => p.sessionId === next); + const epid = participant?.endpointId; + nextEndpointId = epid ?? null; + const matchingEl = epid + ? (videoElements ?? []).find((el) => el.dataset.endpointId === epid) + : undefined; + const container = + (matchingEl?.parentElement as HTMLElement | null) ?? + (matchingEl as HTMLElement | null) ?? + null; + setPinnedContainer(container); + } + desiredPinEndpointIdRef.current = nextEndpointId; + sendPinnedEndpoint(dataChannel, nextEndpointId); + pendingPinPromiseRef.current = pushVideoSourceToBackend(next); + setUserOptionsTarget(null); + }; + const kickParticipant = async () => { const productionId = joinProductionOptions?.productionId || production?.productionId; @@ -539,12 +911,17 @@ export const ProductionLine = ({ joinProductionOptions={joinProductionOptions} audiooutput={audiooutput || undefined} inputAudioStream={inputAudioStream} + inputVideoStream={inputVideoStream} + videoEnabled={videoEnabled} callId={id} dispatch={dispatch} /> )} {!connectionError && !loading && ( - + {line && ( -
+ + {videoEnabled && ( + + {/* Local camera self-preview with fullscreen */} + muteInput(!isInputMuted)} + inputAudioStream={inputAudioStream} + pgmStream={pgmStream} + pinnedRemoteStream={pinnedRemoteStream} + pinnedRemoteName={pinnedRemoteName} + isPinnedRemoteWhip={pinnedRemoteIsWhip} + isVideoMuted={isVideoMuted} + hasCamera={ + !!inputVideoStream && + inputVideoStream !== "no-device" + } + toggleVideo={toggleVideo} + /> + {/* Return Feed — renders itself when shadow stream is active */} + + {/* Remote participant video tiles */} + { + setVideoGridEl(node); + setVideoGridNode(node); + }} + /> + muteInput(!isInputMuted)} + hasCamera={ + !!inputVideoStream && + inputVideoStream !== "no-device" + } + isVideoMuted={isVideoMuted} + onToggleVideo={toggleVideo} + /> + + )} muteInput(!isInputMuted)} handleInputChange={handleInputChange} + videoEnabled={videoEnabled} + isVideoMuted={isVideoMuted} + toggleVideo={toggleVideo} + hasCamera={ + !!inputVideoStream && inputVideoStream !== "no-device" + } /> + {inputAudioStream && inputAudioStream !== "no-device" && !line?.programOutputLine && ( @@ -636,9 +1069,16 @@ export const ProductionLine = ({ dominantSpeaker={dominantSpeaker} audioLevelAboveThreshold={audioLevelAboveThreshold} programOutputLine={line.programOutputLine} + videoEnabled={line.videoEnabled} setConfirmModalOpen={setConfirmModalOpen} setUserId={setUserId} setUserName={setUserName} + pinnedVideoSessionId={pinnedVideoSessionId} + whepSourceSessionId={ + line.whepSourceSessionId ?? null + } + onPin={handlePin} + onSetWhep={handleSetWhep} setKickModalOpen={setKickModalOpen} setKickSessionId={setKickSessionId} setKickUserName={setKickUserName} @@ -665,7 +1105,7 @@ export const ProductionLine = ({ )} )} -
+ {confirmModalOpen && ( @@ -685,6 +1125,16 @@ export const ProductionLine = ({ onCancel={() => setConfirmModalOpen(false)} /> )} + {pendingWhepTargetSessionId && whepConfirmTexts && ( + setPendingWhepTargetSessionId(null)} + /> + )} + {kickModalOpen && ( setHotkeysModalOpen(false)} /> )} + {userOptionsTarget && ( + <> + setUserOptionsTarget(null)} /> + + { + handlePin(userOptionsTarget.sessionId); + }} + onSelectAsWhep={() => + handleSetWhep(userOptionsTarget.sessionId) + } + /> + + + )}
)} diff --git a/src/components/production-line/remote-fullscreen-controls.tsx b/src/components/production-line/remote-fullscreen-controls.tsx new file mode 100644 index 00000000..51caab0b --- /dev/null +++ b/src/components/production-line/remote-fullscreen-controls.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { FullscreenControls } from "./fullscreen-controls"; +import { + isRemoteVideoTile, + useOverlayReveal, +} from "./fullscreen-overlay-utils"; +import { exitTileFullscreen } from "./video-element-factory"; + +type DocumentWithIOSFullscreen = Document & { + webkitFullscreenElement?: Element | null; +}; + +type RemoteFullscreenControlsProps = { + gridEl: HTMLElement | null; + hasMic: boolean; + isInputMuted: boolean; + hasCamera?: boolean; + isVideoMuted?: boolean; + onToggleMute: () => void; + onToggleVideo?: () => void; +}; + +export const RemoteFullscreenControls = ({ + gridEl, + hasMic, + isInputMuted, + hasCamera, + isVideoMuted, + onToggleMute, + onToggleVideo, +}: RemoteFullscreenControlsProps) => { + const [containerEl, setContainerEl] = useState(null); + const isFullscreen = containerEl !== null; + const { showOverlay, reveal, handleMouseMove } = + useOverlayReveal(isFullscreen); + + useEffect(() => { + const handleFsChange = () => { + const doc = document as DocumentWithIOSFullscreen; + const fsEl = document.fullscreenElement ?? doc.webkitFullscreenElement; + if (isRemoteVideoTile(fsEl, gridEl)) { + setContainerEl(fsEl); + } else { + setContainerEl(null); + } + }; + document.addEventListener("fullscreenchange", handleFsChange); + // Sync once in case we mounted while already fullscreen. + handleFsChange(); + return () => { + document.removeEventListener("fullscreenchange", handleFsChange); + }; + }, [gridEl]); + + useEffect(() => { + if (!containerEl) return undefined; + reveal(); + containerEl.addEventListener("mousemove", handleMouseMove); + return () => { + containerEl.removeEventListener("mousemove", handleMouseMove); + }; + }, [containerEl, reveal, handleMouseMove]); + + if (!containerEl) return null; + + return createPortal( + , + containerEl + ); +}; diff --git a/src/components/production-line/return-feed-tile.tsx b/src/components/production-line/return-feed-tile.tsx new file mode 100644 index 00000000..464651c5 --- /dev/null +++ b/src/components/production-line/return-feed-tile.tsx @@ -0,0 +1,295 @@ +import styled from "@emotion/styled"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + FullscreenExitIcon, + FullscreenIcon, +} from "../../assets/icons/icon.tsx"; +import { TUseVideoInputValues } from "./use-video-input"; +import { + exitTileFullscreen, + isTileFullscreen, + makePipDraggable, + requestTileFullscreen, +} from "./video-element-factory"; + +type FullscreenState = { + isFullscreen: boolean; + showOverlay: boolean; +}; + +const Container = styled.div` + position: relative; + width: 100%; + aspect-ratio: ${({ isFullscreen }) => (isFullscreen ? "auto" : "16 / 9")}; + height: ${({ isFullscreen }) => (isFullscreen ? "100%" : "auto")}; + background-color: #000; + border-radius: ${({ isFullscreen }) => (isFullscreen ? "0" : "0.5rem")}; + overflow: hidden; + cursor: ${({ isFullscreen, showOverlay }) => + isFullscreen && !showOverlay ? "none" : "default"}; + margin-bottom: ${({ isFullscreen }) => (isFullscreen ? "0" : "0.5rem")}; +`; + +const ReturnVideo = styled.video` + width: 100%; + height: 100%; + object-fit: cover; + background-color: #000; + display: block; +`; + +const Label = styled.span` + position: absolute; + top: 0.3rem; + left: 0.3rem; + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + font-size: 1.1rem; + font-weight: 600; + color: #1a1a1a; + background-color: rgba(89, 203, 232, 0.82); + pointer-events: none; + letter-spacing: 0.05em; + opacity: ${({ isFullscreen, showOverlay }) => + isFullscreen && !showOverlay ? 0 : 1}; + transition: opacity 0.3s; +`; + +const FullscreenButton = styled.button` + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + border: none; + border-radius: 0.25rem; + cursor: pointer; + color: #fff; + padding: 0; + opacity: ${({ isFullscreen, showOverlay }) => { + if (!isFullscreen) return 0.7; + return showOverlay ? 1 : 0; + }}; + transition: opacity 0.3s; + + & svg { + width: 1.8rem; + height: 1.8rem; + fill: currentColor; + display: block; + } +`; + +const PipContainer = styled.div<{ visible: boolean }>` + display: ${({ visible }) => (visible ? "block" : "none")}; + position: absolute; + bottom: 1rem; + left: 1rem; + width: 22rem; + height: 12.375rem; + border-radius: 0.5rem; + overflow: hidden; + border: 2px solid rgba(123, 226, 123, 0.55); + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.7); + z-index: 11; +`; + +const PipVideo = styled.video` + width: 100%; + height: 100%; + object-fit: cover; + display: block; + background-color: #000; +`; + +const PipLabel = styled.span` + position: absolute; + top: 0.3rem; + left: 0.3rem; + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + font-size: 1.1rem; + font-weight: 600; + color: #1a1a1a; + background-color: rgba(123, 226, 123, 0.82); + pointer-events: none; + letter-spacing: 0.05em; +`; + +const FullscreenOverlay = styled.div<{ visible: boolean }>` + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 5rem 2rem 2.5rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.65)); + display: flex; + align-items: center; + justify-content: center; + opacity: ${({ visible }) => (visible ? 1 : 0)}; + transition: opacity 0.3s; + pointer-events: ${({ visible }) => (visible ? "auto" : "none")}; +`; + +const ExitFullscreenButton = styled.button` + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); + border: 2px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + + & svg { + width: 1.8rem; + height: 1.8rem; + fill: currentColor; + display: block; + } +`; + +type ReturnFeedTileProps = { + stream: MediaStream | null; + cameraStream?: TUseVideoInputValues; + label?: string | null; + cameraLabel?: string | null; +}; + +export const ReturnFeedTile = ({ + stream, + cameraStream, + label, + cameraLabel, +}: ReturnFeedTileProps) => { + const videoRef = useRef(null); + const pipVideoRef = useRef(null); + const pipContainerRef = useRef(null); + const containerRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showOverlay, setShowOverlay] = useState(false); + const hideTimerRef = useRef | undefined>( + undefined + ); + + // Sync return-feed stream + useEffect(() => { + const el = videoRef.current; + if (!el) return; + el.srcObject = stream; + if (stream) el.play().catch(() => {}); + }, [stream]); + + // Sync camera PiP stream + useEffect(() => { + const el = pipVideoRef.current; + if (!el) return; + if (cameraStream && cameraStream !== "no-device") { + el.srcObject = cameraStream; + el.play().catch(() => {}); + } else { + el.srcObject = null; + } + }, [cameraStream]); + + useEffect(() => { + const containerEl = containerRef.current; + const handler = () => + setIsFullscreen(!!containerEl && isTileFullscreen(containerEl)); + document.addEventListener("fullscreenchange", handler); + const videoEl = videoRef.current; + const onIOSEnter = () => setIsFullscreen(true); + const onIOSExit = () => setIsFullscreen(false); + videoEl?.addEventListener("webkitbeginfullscreen", onIOSEnter); + videoEl?.addEventListener("webkitendfullscreen", onIOSExit); + return () => { + document.removeEventListener("fullscreenchange", handler); + videoEl?.removeEventListener("webkitbeginfullscreen", onIOSEnter); + videoEl?.removeEventListener("webkitendfullscreen", onIOSExit); + }; + }, []); + + useEffect(() => () => clearTimeout(hideTimerRef.current), []); + + useEffect(() => { + const pip = pipContainerRef.current; + const parent = containerRef.current; + if (!pip || !parent) return undefined; + return makePipDraggable(pip, parent); + }, []); + + const revealOverlay = useCallback(() => { + setShowOverlay(true); + clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => setShowOverlay(false), 3000); + }, []); + + const handleMouseMove = useCallback(() => { + if (isFullscreen) revealOverlay(); + }, [isFullscreen, revealOverlay]); + + const toggleFullscreen = useCallback(() => { + const container = containerRef.current; + if (!container) return; + if (isTileFullscreen(container)) { + exitTileFullscreen(); + } else { + requestTileFullscreen(container, videoRef.current); + revealOverlay(); + } + }, [revealOverlay]); + + if (!stream) return null; + + const hasCameraPip = !!(cameraStream && cameraStream !== "no-device"); + const overlayVisible = isFullscreen && showOverlay; + + return ( + + + + + + + {isFullscreen ? : } + + + + + {cameraLabel?.trim() ? cameraLabel : "CAM"} + + + + + + + + + ); +}; diff --git a/src/components/production-line/select-devices.tsx b/src/components/production-line/select-devices.tsx index 3cf0e54d..e54eabd3 100644 --- a/src/components/production-line/select-devices.tsx +++ b/src/components/production-line/select-devices.tsx @@ -61,13 +61,16 @@ export const SelectDevices = ({ // Watch all form values const watchedValues = useWatch({ - name: ["audioinput", "audiooutput"], + name: ["audioinput", "audiooutput", "videoinput"], control, }); const audioInputTheSame = joinProductionOptions?.audioinput === watchedValues[0]; const audioOutputTheSame = audiooutput === watchedValues[1]; - const audioNotChanged = audioInputTheSame && audioOutputTheSame; + const videoInputTheSame = + joinProductionOptions?.videoinput === watchedValues[2]; + const audioNotChanged = + audioInputTheSame && audioOutputTheSame && videoInputTheSame; // Reset connection and re-connect to production-line const onSubmit: SubmitHandler = async (payload) => { @@ -90,11 +93,9 @@ export const SelectDevices = ({ muteInput(); const newJoinProductionOptions = { - ...payload, - isProgramUser: joinProductionOptions.isProgramUser, - productionId: joinProductionOptions.productionId, - lineId: joinProductionOptions.lineId, - username: joinProductionOptions.username, + ...joinProductionOptions, + audioinput: payload.audioinput, + videoinput: payload.videoinput, }; dispatch({ @@ -105,10 +106,12 @@ export const SelectDevices = ({ joinProductionOptions: newJoinProductionOptions, audiooutput: payload.audiooutput, mediaStreamInput: null, + mediaStreamVideoInput: null, dominantSpeaker: null, audioLevelAboveThreshold: false, connectionState: null, audioElements: null, + videoElements: null, sessionId: null, }, }, @@ -169,6 +172,25 @@ export const SelectDevices = ({ )} )} + {line?.videoEnabled && !joinProductionOptions.isProgramUser && ( + + Camera + + + {devices.videoInput && devices.videoInput.length > 0 + ? devices.videoInput.map((device) => ( + + )) + : null} + + + )} {!(isBrowserFirefox && !isMobile) && } ( + + + +); + +const CameraOffOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + color: rgba(255, 255, 255, 0.85); + z-index: 2; +`; + +const CameraOffGlyphSlot = styled.span` + display: inline-flex; + width: 6rem; + height: 6rem; +`; + +const FullscreenIcon = () => ( + + + +); + +const FullscreenExitIcon = () => ( + + + +); + +type FullscreenState = { + isFullscreen: boolean; + showOverlay: boolean; +}; + +const Container = styled.div` + position: relative; + width: 100%; + aspect-ratio: ${({ isFullscreen }) => (isFullscreen ? "auto" : "16 / 9")}; + height: ${({ isFullscreen }) => (isFullscreen ? "100%" : "auto")}; + background-color: #000; + border-radius: ${({ isFullscreen }) => (isFullscreen ? "0" : "0.5rem")}; + overflow: hidden; + cursor: ${({ isFullscreen, showOverlay }) => + isFullscreen && !showOverlay ? "none" : "default"}; + margin-bottom: ${({ isFullscreen }) => (isFullscreen ? "0" : "0.5rem")}; +`; + +const PreviewVideo = styled.video` + width: 100%; + height: 100%; + object-fit: cover; + background-color: #000; + display: block; + transform: scaleX(-1); +`; + +const UsernameLabel = styled.span` + position: absolute; + bottom: 0.25rem; + right: 0.25rem; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem 0.25rem 0.25rem 0.25rem; + font-size: 1.4rem; + font-weight: 600; + color: #1a1a1a; + background-color: rgba(123, 226, 123, 0.82); + max-width: calc(100% - 0.5rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + opacity: ${({ isFullscreen, showOverlay }) => + isFullscreen && !showOverlay ? 0 : 1}; + transition: opacity 0.3s; +`; + +const FullscreenButton = styled.button` + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + border: none; + border-radius: 0.25rem; + cursor: pointer; + color: #fff; + padding: 0; + opacity: ${({ isFullscreen, showOverlay }) => { + if (!isFullscreen) return 0.7; + return showOverlay ? 1 : 0; + }}; + transition: opacity 0.3s; +`; + +const WHIP_BORDER = "rgba(89, 203, 232, 0.55)"; +const WHIP_BG = "rgba(89, 203, 232, 0.82)"; +const USER_BORDER = "rgba(123, 226, 123, 0.55)"; +const USER_BG = "rgba(123, 226, 123, 0.82)"; + +const PipContainer = styled.div<{ visible: boolean; isWhip: boolean }>` + display: ${({ visible }) => (visible ? "block" : "none")}; + position: absolute; + bottom: 1rem; + left: 1rem; + width: 22rem; + height: 12.375rem; + border-radius: 0.5rem; + overflow: hidden; + border: 2px solid ${({ isWhip }) => (isWhip ? WHIP_BORDER : USER_BORDER)}; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.7); + z-index: 11; +`; + +const PipVideo = styled.video` + width: 100%; + height: 100%; + object-fit: cover; + display: block; + background-color: #000; +`; + +const PipLabel = styled.span<{ isWhip: boolean }>` + position: absolute; + top: 0.3rem; + left: 0.3rem; + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + font-size: 1.1rem; + font-weight: 600; + color: #1a1a1a; + background-color: ${({ isWhip }) => (isWhip ? WHIP_BG : USER_BG)}; + pointer-events: none; + letter-spacing: 0.05em; +`; + +type SelfPreviewTileProps = { + stream: TUseVideoInputValues; + username?: string; + isInputMuted: boolean; + inputAudioStream: TUseAudioInputValues; + pgmStream?: MediaStream | null; + pinnedRemoteStream?: MediaStream | null; + pinnedRemoteName?: string | null; + isPinnedRemoteWhip?: boolean | null; + isVideoMuted?: boolean; + hasCamera?: boolean; + muteInput: () => void; + toggleVideo?: () => void; +}; + +export const SelfPreviewTile = ({ + stream, + username, + isInputMuted, + inputAudioStream, + pgmStream, + pinnedRemoteStream, + pinnedRemoteName, + isPinnedRemoteWhip, + isVideoMuted, + hasCamera, + muteInput, + toggleVideo, +}: SelfPreviewTileProps) => { + const pipStream = pgmStream ?? pinnedRemoteStream ?? null; + const pipLabel = pgmStream ? "PGM" : (pinnedRemoteName ?? "REMOTE"); + const pipIsWhipAccent = pgmStream ? true : isPinnedRemoteWhip !== false; + const videoRef = useRef(null); + const pgmVideoRef = useRef(null); + const pipContainerRef = useRef(null); + const containerRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const { showOverlay, reveal, handleMouseMove } = + useOverlayReveal(isFullscreen); + + // Sync camera stream to video element + useEffect(() => { + const el = videoRef.current; + if (!el) return; + if (stream && stream !== "no-device") { + el.srcObject = stream; + el.play().catch(() => {}); + } else { + el.srcObject = null; + } + }, [stream]); + + useEffect(() => { + const el = pgmVideoRef.current; + if (!el) return; + el.srcObject = pipStream; + if (pipStream) el.play().catch(() => {}); + }, [pipStream]); + + useEffect(() => { + const containerEl = containerRef.current; + const handler = () => + setIsFullscreen(!!containerEl && isTileFullscreen(containerEl)); + document.addEventListener("fullscreenchange", handler); + const videoEl = videoRef.current; + const onIOSEnter = () => setIsFullscreen(true); + const onIOSExit = () => setIsFullscreen(false); + videoEl?.addEventListener("webkitbeginfullscreen", onIOSEnter); + videoEl?.addEventListener("webkitendfullscreen", onIOSExit); + return () => { + document.removeEventListener("fullscreenchange", handler); + videoEl?.removeEventListener("webkitbeginfullscreen", onIOSEnter); + videoEl?.removeEventListener("webkitendfullscreen", onIOSExit); + }; + }, []); + + useEffect(() => { + const pip = pipContainerRef.current; + const parent = containerRef.current; + if (!pip || !parent) return undefined; + return makePipDraggable(pip, parent); + }, []); + + const toggleFullscreen = useCallback(() => { + const container = containerRef.current; + if (!container) return; + if (isTileFullscreen(container)) { + exitTileFullscreen(); + } else { + requestTileFullscreen(container, videoRef.current); + reveal(); + } + }, [reveal]); + + if (!stream || stream === "no-device") return null; + + const hasMic = !!(inputAudioStream && inputAudioStream !== "no-device"); + const overlayVisible = isFullscreen && showOverlay; + + return ( + + + + {isVideoMuted && ( + + + + + + )} + + {username && ( + + {username} + + )} + + + {isFullscreen ? : } + + + + + {pipLabel} + + + + + ); +}; diff --git a/src/components/production-line/symphony-rtc-connection-component.tsx b/src/components/production-line/symphony-rtc-connection-component.tsx index 1ba80bfc..a63fc602 100644 --- a/src/components/production-line/symphony-rtc-connection-component.tsx +++ b/src/components/production-line/symphony-rtc-connection-component.tsx @@ -4,12 +4,15 @@ import { useEstablishSession } from "./use-establish-session"; import { useRtcConnection } from "./use-rtc-connection"; import { THeartbeatStatus, useHeartbeat } from "./use-heartbeat"; import { TUseAudioInputValues } from "./use-audio-input"; +import { TUseVideoInputValues } from "./use-video-input"; import { useCallStateSync } from "./use-call-state-sync"; type SymphonyRtcConnectionComponentProps = { joinProductionOptions: TJoinProductionOptions | null; audiooutput: string | undefined; inputAudioStream: TUseAudioInputValues; + inputVideoStream: TUseVideoInputValues; + videoEnabled: boolean; callId: string; dispatch: React.Dispatch; onHeartbeatStatus?: (status: THeartbeatStatus) => void; @@ -20,6 +23,8 @@ export const SymphonyRtcConnectionComponent = ({ joinProductionOptions, audiooutput, inputAudioStream, + inputVideoStream, + videoEnabled, callId, dispatch, onHeartbeatStatus, @@ -32,8 +37,10 @@ export const SymphonyRtcConnectionComponent = ({ onError: onEstablishError, }); - const { connectionState, audioElements } = useRtcConnection({ + const { connectionState, audioElements, videoElements } = useRtcConnection({ inputAudioStream, + inputVideoStream, + videoEnabled, sdpOffer, joinProductionOptions, audiooutput, @@ -50,6 +57,7 @@ export const SymphonyRtcConnectionComponent = ({ stateUpdates: { connectionState, audioElements, + videoElements, sessionId, }, }); diff --git a/src/components/production-line/types.ts b/src/components/production-line/types.ts index 507bf81d..10babd81 100644 --- a/src/components/production-line/types.ts +++ b/src/components/production-line/types.ts @@ -5,10 +5,12 @@ export type TJoinProductionOptions = { username: string; // Not all devices have input available audioinput?: string; + videoinput?: string; lineUsedForProgramOutput: boolean; isProgramUser: boolean; lineName?: string; productionName?: string; + videoEnabled?: boolean; }; export type Hotkeys = { @@ -26,6 +28,8 @@ export type TParticipant = { endpointId: string; isActive: boolean; isWhip: boolean; + isWhepReceiver?: boolean; + hasVideo?: boolean; }; export type TLine = { @@ -34,6 +38,8 @@ export type TLine = { smbConferenceId?: string; participants: TParticipant[]; programOutputLine?: boolean; + videoEnabled?: boolean; + whepSourceSessionId?: string | null; }; export type TBasicProduction = { diff --git a/src/components/production-line/use-call-state-sync.ts b/src/components/production-line/use-call-state-sync.ts index c2e8b5da..9a7ba96d 100644 --- a/src/components/production-line/use-call-state-sync.ts +++ b/src/components/production-line/use-call-state-sync.ts @@ -4,6 +4,7 @@ import { TGlobalStateAction } from "../../global-state/global-state-actions"; type TStateUpdates = { connectionState: RTCPeerConnectionState | null; audioElements: HTMLAudioElement[]; + videoElements: HTMLVideoElement[]; sessionId: string | null; }; @@ -41,6 +42,16 @@ export const useCallStateSync = ({ }); }, [stateUpdates.audioElements, callId, dispatch]); + useEffect(() => { + dispatch({ + type: "UPDATE_CALL", + payload: { + id: callId, + updates: { videoElements: stateUpdates.videoElements }, + }, + }); + }, [stateUpdates.videoElements, callId, dispatch]); + useEffect(() => { if (stateUpdates.sessionId !== null) { dispatch({ diff --git a/src/components/production-line/use-heartbeat.test.ts b/src/components/production-line/use-heartbeat.test.ts index 8b9269a5..bf70fb2c 100644 --- a/src/components/production-line/use-heartbeat.test.ts +++ b/src/components/production-line/use-heartbeat.test.ts @@ -36,7 +36,7 @@ const mockState: TGlobalState = { reloadPresetList: false, production: null, selectedProductionId: null, - devices: { input: null, output: null }, + devices: { input: null, output: null, videoInput: null }, userSettings: null, apiError: false, websocket: null, diff --git a/src/components/production-line/use-line-polling.test.ts b/src/components/production-line/use-line-polling.test.ts index ed50a2bf..0d457f42 100644 --- a/src/components/production-line/use-line-polling.test.ts +++ b/src/components/production-line/use-line-polling.test.ts @@ -43,7 +43,7 @@ const mockState: TGlobalState = { reloadPresetList: false, production: null, selectedProductionId: null, - devices: { input: null, output: null }, + devices: { input: null, output: null, videoInput: null }, userSettings: null, apiError: false, websocket: null, @@ -70,6 +70,7 @@ const participant = (name: string): TParticipant => ({ endpointId: `endpoint-${name}`, isActive: true, isWhip: false, + hasVideo: false, }); const line: TLine = { @@ -207,7 +208,7 @@ describe("useLinePolling", () => { expect(mockFetchLineParticipants).toHaveBeenCalledTimes(2); }); - it("stops polling after 10 consecutive failures", async () => { + it("keeps polling past 10 consecutive failures so a brief backend outage recovers on its own", async () => { mockFetchProductionLine.mockResolvedValue(line); mockFetchLineParticipants.mockRejectedValue(new Error("boom")); @@ -218,7 +219,6 @@ describe("useLinePolling", () => { await flush(); - // Drain all backoff timers well past the 10-failure bail-out. for (let i = 0; i < 15; i += 1) { // eslint-disable-next-line no-await-in-loop await act(async () => { @@ -226,18 +226,18 @@ describe("useLinePolling", () => { }); } - const callsAfterBailout = mockFetchLineParticipants.mock.calls.length; + const callsSoFar = mockFetchLineParticipants.mock.calls.length; + expect(callsSoFar).toBeGreaterThan(10); - // No further timers should be pending — the loop has bailed out. + // Still retrying — the loop never bails out. await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); - - expect(mockFetchLineParticipants).toHaveBeenCalledTimes( - callsAfterBailout + expect(mockFetchLineParticipants.mock.calls.length).toBeGreaterThan( + callsSoFar ); - expect(callsAfterBailout).toBe(10); - expect(mockDispatch).toHaveBeenCalledWith( + + expect(mockDispatch).not.toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ error: expect.objectContaining({ @@ -249,6 +249,62 @@ describe("useLinePolling", () => { }); }); + describe("line metadata refresh", () => { + it("picks up a whepSourceSessionId change for a client already in the call", async () => { + mockFetchProductionLine.mockResolvedValueOnce({ + ...line, + whepSourceSessionId: null, + }); + mockFetchLineParticipants + .mockResolvedValueOnce([participant("initial")]) + .mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useLinePolling({ callId: "call-1", joinProductionOptions }), + { wrapper } + ); + + await flush(); + expect(result.current?.whepSourceSessionId ?? null).toBeNull(); + + // Someone else sets the WHEP source server-side. + mockFetchProductionLine.mockResolvedValue({ + ...line, + whepSourceSessionId: "session-other", + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(result.current?.whepSourceSessionId).toBe("session-other"); + }); + + it("does not let the metadata refresh clobber long-polled participants", async () => { + mockFetchProductionLine.mockResolvedValue({ + ...line, + participants: [participant("stale")], + }); + mockFetchLineParticipants + .mockResolvedValueOnce([participant("fresh")]) + .mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useLinePolling({ callId: "call-1", joinProductionOptions }), + { wrapper } + ); + + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(result.current?.participants.map((p) => p.name)).toEqual([ + "fresh", + ]); + }); + }); + describe("auth circuit breaker gating", () => { it("pauses the initial seed while the breaker is reauthing and runs it on resume", async () => { let resolveReauth!: () => void; @@ -323,8 +379,12 @@ describe("useLinePolling", () => { }); await flush(); + // The gated seed resumed. The exact count is deliberately not pinned: + // the line-metadata refresh interval also calls fetchProductionLine once + // a second, and this test advances the clock 40s to reach the half-open + // retry. What matters is 0 calls while gated -> at least one after. expect(authCircuitBreaker.isActive()).toBe(true); - expect(mockFetchProductionLine).toHaveBeenCalledTimes(1); + expect(mockFetchProductionLine).toHaveBeenCalled(); }); }); diff --git a/src/components/production-line/use-line-polling.ts b/src/components/production-line/use-line-polling.ts index 0cef7eda..73100bbf 100644 --- a/src/components/production-line/use-line-polling.ts +++ b/src/components/production-line/use-line-polling.ts @@ -12,13 +12,15 @@ type TProps = { joinProductionOptions: TJoinProductionOptions | null; }; +const LINE_METADATA_REFRESH_MS = 1000; + const isAbortError = (err: unknown): boolean => err instanceof DOMException && err.name === "AbortError"; -// Fetches the line once for its metadata, then keeps the participant list -// current through the manager's long-poll endpoint instead of interval polling: -// each request is held open server-side until participants change (or it times -// out), and is re-issued immediately when it resolves. +// Keeps the participant list current through the manager's long-poll endpoint +// (each request is held open server-side until participants change, then is +// re-issued immediately), and refreshes the rest of the line on an interval +// since the long-poll does not carry line-level fields. export const useLinePolling = ({ callId, joinProductionOptions }: TProps) => { const [line, setLine] = useState(null); const [, dispatch] = useGlobalState(); @@ -33,13 +35,14 @@ export const useLinePolling = ({ callId, joinProductionOptions }: TProps) => { const controller = new AbortController(); const productionId = parseInt(joinProductionOptions.productionId, 10); const lineId = parseInt(joinProductionOptions.lineId, 10); + const ERROR_AFTER_FAILURES = 5; const handleFailure = () => { consecutiveFailureCount += 1; logger.red( `Error fetching production line ${productionId}/${lineId}. For call-id: ${callId}` ); - if (consecutiveFailureCount >= 5) { + if (consecutiveFailureCount === ERROR_AFTER_FAILURES) { dispatch({ type: "ERROR", payload: { @@ -50,19 +53,13 @@ export const useLinePolling = ({ callId, joinProductionOptions }: TProps) => { }, }); } - if (consecutiveFailureCount >= 10) { - dispatch({ - type: "ERROR", - payload: { - callId, - error: new Error( - "Line polling stopped after 10 consecutive failures." - ), - }, - }); - return false; + }; + + // Clear a previously surfaced polling error once a request succeeds again. + const clearErrorOnRecovery = () => { + if (consecutiveFailureCount >= ERROR_AFTER_FAILURES) { + dispatch({ type: "ERROR", payload: { callId, error: null } }); } - return true; }; // Respect the global auth circuit breaker. Returns true when the caller may @@ -92,18 +89,18 @@ export const useLinePolling = ({ callId, joinProductionOptions }: TProps) => { API.fetchLineParticipants(productionId, lineId, controller.signal) .then((participants) => { if (cancelled) return; + clearErrorOnRecovery(); consecutiveFailureCount = 0; setLine((prev) => (prev ? { ...prev, participants } : prev)); poll(); }) .catch((err) => { if (cancelled || isAbortError(err)) return; - if (handleFailure()) { - retryTimeout = setTimeout( - poll, - backoffDelayMs(consecutiveFailureCount) - ); - } + handleFailure(); + retryTimeout = setTimeout( + poll, + backoffDelayMs(consecutiveFailureCount) + ); }); }; @@ -116,25 +113,46 @@ export const useLinePolling = ({ callId, joinProductionOptions }: TProps) => { API.fetchProductionLine(productionId, lineId) .then((l) => { if (cancelled) return; + clearErrorOnRecovery(); consecutiveFailureCount = 0; setLine(l); poll(); }) .catch((err) => { if (cancelled || isAbortError(err)) return; - if (handleFailure()) { - retryTimeout = setTimeout( - seed, - backoffDelayMs(consecutiveFailureCount) - ); - } + handleFailure(); + retryTimeout = setTimeout( + seed, + backoffDelayMs(consecutiveFailureCount) + ); + }); + }; + + const refreshLineMetadata = () => { + API.fetchProductionLine(productionId, lineId) + .then((l) => { + if (cancelled) return; + setLine((prev) => + prev ? { ...l, participants: prev.participants } : l + ); + }) + .catch(() => { + if (cancelled) return; + logger.red( + `Error refreshing line metadata ${productionId}/${lineId}. For call-id: ${callId}` + ); }); }; seed(); + const metadataInterval = window.setInterval( + refreshLineMetadata, + LINE_METADATA_REFRESH_MS + ); return () => { cancelled = true; + window.clearInterval(metadataInterval); if (retryTimeout !== null) clearTimeout(retryTimeout); resumeUnsub?.(); controller.abort(); diff --git a/src/components/production-line/use-rtc-connection.test.ts b/src/components/production-line/use-rtc-connection.test.ts index 27005f2b..286d931e 100644 --- a/src/components/production-line/use-rtc-connection.test.ts +++ b/src/components/production-line/use-rtc-connection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; import { useRtcConnection } from "./use-rtc-connection.ts"; import { GlobalStateContext } from "../../global-state/context-provider.tsx"; @@ -8,11 +8,18 @@ import { TUseAudioInputValues } from "./use-audio-input.ts"; // ── Global RTCPeerConnection stub (not available in happy-dom) ────── +const rtcInstances: MockRTCPeerConnection[] = []; + // Captures the data-channel "message" listener registered by // establishConnection so tests can drive incoming messages through it. let capturedMessageHandler: ((event: MessageEvent) => void) | null = null; class MockRTCPeerConnection extends EventTarget { + constructor() { + super(); + rtcInstances.push(this); + } + connectionState: RTCPeerConnectionState = "new"; iceGatheringState: RTCIceGatheringState = "new"; @@ -85,7 +92,7 @@ const mockState: TGlobalState = { reloadPresetList: false, production: null, selectedProductionId: null, - devices: { input: null, output: null }, + devices: { input: null, output: null, videoInput: null }, userSettings: null, apiError: false, websocket: null, @@ -100,6 +107,8 @@ const wrapper = ({ children }: { children: ReactNode }) => const defaultOptions = { inputAudioStream: null as TUseAudioInputValues, + inputVideoStream: null as null, + videoEnabled: false, sdpOffer: null, joinProductionOptions: null, audiooutput: undefined, @@ -181,6 +190,61 @@ describe("useRtcConnection", () => { ); }); + it("clears a video tile's stale lastSessionId when its slot is recycled (unmute)", () => { + // The backend reuses a fixed pool of receive slots and never + // renegotiates: when the pinned publisher leaves and a new one is + // selected, SMB rewrites the new publisher onto the SAME slot/SSRC, which + // fires `unmute`. If the tile keeps the departed publisher's + // lastSessionId, computeVideoTileLabels can never rebind it and the grid + // drops it as stale → permanent black screen. unmute must void the + // binding so the matcher re-adopts the slot for the now-active source. + rtcInstances.length = 0; + const { result } = renderHook( + () => + useRtcConnection({ + ...defaultOptions, + inputAudioStream: "no-device", + sdpOffer: "v=0\r\n", + sessionId: "session-1", + joinProductionOptions: { + productionId: "1", + lineId: "line-1", + username: "user", + lineUsedForProgramOutput: false, + isProgramUser: false, + }, + }), + { wrapper } + ); + + const pc = rtcInstances[rtcInstances.length - 1]; + const stream = new MediaStream(); + const track = Object.assign(new EventTarget(), { + kind: "video", + id: "video-track-1", + }); + + act(() => { + const trackEvent = Object.assign(new Event("track"), { + streams: [stream], + track, + }); + pc.dispatchEvent(trackEvent); + }); + + const videoEl = result.current.videoElements[0]; + expect(videoEl).toBeDefined(); + + // The grid binds the tile to the publisher currently feeding the slot. + videoEl.dataset.lastSessionId = "departed-publisher"; + + act(() => { + track.dispatchEvent(new Event("unmute")); + }); + + expect(videoEl.dataset.lastSessionId).toBe(""); + }); + it("does not connect when inputAudioStream is null", () => { renderHook( () => diff --git a/src/components/production-line/use-rtc-connection.ts b/src/components/production-line/use-rtc-connection.ts index b3d3c6dd..9953fc4f 100644 --- a/src/components/production-line/use-rtc-connection.ts +++ b/src/components/production-line/use-rtc-connection.ts @@ -6,6 +6,11 @@ import { TGlobalStateAction } from "../../global-state/global-state-actions.ts"; import { noop } from "../../helpers"; import logger from "../../utils/logger.ts"; import { createAudioElement } from "./audio-element-factory.ts"; +import { + attachShowWhenReady, + createVideoElement, + stopFrameMonitor, +} from "./video-element-factory.ts"; import { parseDataChannelMessage, isRemoteMute, @@ -15,11 +20,15 @@ import { waitForIceGathering } from "./ice-gathering.ts"; import { startRtcStatInterval } from "./rtc-stat-interval.ts"; import { TJoinProductionOptions } from "./types.ts"; import { useAudioElements } from "./use-audio-elements.ts"; +import { useVideoElements } from "./use-video-elements.ts"; import { TUseAudioInputValues } from "./use-audio-input.ts"; +import { TUseVideoInputValues } from "./use-video-input.ts"; import { useRtcDebugLogger } from "./use-rtc-debug-logger.ts"; type TRtcConnectionOptions = { inputAudioStream: TUseAudioInputValues; + inputVideoStream: TUseVideoInputValues; + videoEnabled: boolean; sdpOffer: string | null; joinProductionOptions: TJoinProductionOptions | null; audiooutput: string | undefined; @@ -36,6 +45,7 @@ type TEstablishConnection = { callId: string; dispatch: Dispatch; setAudioElements: Dispatch>; + setVideoElements: Dispatch>; setNoStreamError: (input: boolean) => void; }; @@ -44,6 +54,11 @@ type TAttachAudioStream = { rtcPeerConnection: RTCPeerConnection; }; +type TAttachVideoStream = { + inputVideoStream: MediaStream; + rtcPeerConnection: RTCPeerConnection; +}; + const attachInputAudioToPeerConnection = ({ inputAudioStream, rtcPeerConnection, @@ -54,6 +69,34 @@ const attachInputAudioToPeerConnection = ({ .forEach((track) => rtcPeerConnection.addTrack(track)); }; +const VIDEO_MAX_BITRATE = 2_000_000; +const VIDEO_MAX_FRAMERATE = 25; + +const applyVideoSenderParameters = async (sender: RTCRtpSender) => { + const params = sender.getParameters(); + if (!params.encodings || params.encodings.length === 0) { + params.encodings = [{}]; + } + params.encodings[0].maxBitrate = VIDEO_MAX_BITRATE; + params.encodings[0].maxFramerate = VIDEO_MAX_FRAMERATE; + try { + await sender.setParameters(params); + } catch (err) { + logger.yellow(`[useRtcConnection] setParameters failed: ${String(err)}`); + } +}; + +const attachInputVideoToPeerConnection = ({ + inputVideoStream, + rtcPeerConnection, +}: TAttachVideoStream) => { + if (rtcPeerConnection.signalingState === "closed") return; + inputVideoStream.getTracks().forEach((track) => { + const sender = rtcPeerConnection.addTrack(track); + if (track.kind === "video") applyVideoSenderParameters(sender); + }); +}; + const establishConnection = ({ rtcPeerConnection, sdpOffer, @@ -63,6 +106,7 @@ const establishConnection = ({ callId, dispatch, setAudioElements, + setVideoElements, setNoStreamError, }: TEstablishConnection): { teardown: () => void } => { const lineId = joinProductionOptions.lineId || "unknown"; @@ -73,10 +117,21 @@ const establishConnection = ({ // Chromium, where inbound-rtp audioLevel drops to ~0 when muted). let remoteStream: MediaStream | null = null; - const onRtcTrack = ({ streams }: RTCTrackEvent) => { - const selectedStream = streams[0]; + const onRtcTrack = ({ streams, track }: RTCTrackEvent) => { + if (track.kind === "audio") { + const selectedStream = streams[0]; + if (!selectedStream) { + setNoStreamError(true); + dispatch({ + type: "ERROR", + payload: { + callId, + error: new Error("Stream-error: No MediaStream available"), + }, + }); + return; + } - if (selectedStream && selectedStream.getAudioTracks().length !== 0) { remoteStream = selectedStream; const audioElement = createAudioElement({ @@ -90,25 +145,55 @@ const establishConnection = ({ dispatch({ type: "ERROR", payload: { callId, error } }); }, }); - setAudioElements((prevArray) => [audioElement, ...prevArray]); - } else if (selectedStream && selectedStream.getAudioTracks().length === 0) { - setNoStreamError(true); - dispatch({ - type: "ERROR", - payload: { - callId, - error: new Error("Stream-error: No MediaStreamTracks avaliable"), - }, - }); - } else { - setNoStreamError(true); - dispatch({ - type: "ERROR", - payload: { - callId, - error: new Error("Stream-error: No MediaStream avaliable"), - }, + } else if (track.kind === "video") { + const selectedStream = streams[0] ?? new MediaStream([track]); + setVideoElements((prev) => { + if ( + prev.some( + (el) => + el.srcObject === selectedStream || + (el.srcObject instanceof MediaStream && + el.srcObject.getTracks().some((t) => t.id === track.id)) + ) + ) { + return prev; + } + const endpointId = streams[0]?.id ?? null; + const videoElement = createVideoElement({ + stream: selectedStream, + lineId, + endpointId, + }); + const removeThisElement = () => { + stopFrameMonitor(videoElement); + setVideoElements((current) => + current.filter((el) => el !== videoElement) + ); + videoElement.pause(); + videoElement.srcObject = null; + }; + + track.addEventListener("ended", removeThisElement); + track.addEventListener("unmute", () => { + videoElement.dataset.lastSessionId = ""; + const parent = videoElement.parentElement; + if (parent) attachShowWhenReady(videoElement, parent); + videoElement.play().catch((err) => { + logger.yellow(`[useRtcConnection] video play() failed: ${err}`); + }); + setVideoElements((current) => [...current]); + }); + + if (selectedStream instanceof MediaStream) { + selectedStream.addEventListener("removetrack", (event) => { + if (event.track.id === track.id) { + removeThisElement(); + } + }); + } + + return [videoElement, ...prev]; }); } }; @@ -216,6 +301,8 @@ const establishConnection = ({ export const useRtcConnection = ({ inputAudioStream, + inputVideoStream, + videoEnabled, sdpOffer, joinProductionOptions, audiooutput, @@ -235,6 +322,7 @@ export const useRtcConnection = ({ const [connectionState, setConnectionState] = useState(null); const { audioElements, setAudioElements } = useAudioElements(); + const { videoElements, setVideoElements } = useVideoElements(); const [noStreamError, setNoStreamError] = useState(false); const navigate = useNavigate(); @@ -249,7 +337,8 @@ export const useRtcConnection = ({ !sdpOffer || !sessionId || !joinProductionOptions || - !inputAudioStream + !inputAudioStream || + (videoEnabled && inputVideoStream === null) ) { return noop; } @@ -284,6 +373,21 @@ export const useRtcConnection = ({ }); } + if (videoEnabled && inputVideoStream && inputVideoStream !== "no-device") { + attachInputVideoToPeerConnection({ + rtcPeerConnection, + inputVideoStream, + }); + + dispatch({ + type: "UPDATE_CALL", + payload: { + id: callId, + updates: { mediaStreamVideoInput: inputVideoStream }, + }, + }); + } + const { teardown } = establishConnection({ rtcPeerConnection, sdpOffer, @@ -293,6 +397,7 @@ export const useRtcConnection = ({ callId, dispatch, setAudioElements, + setVideoElements, setNoStreamError, }); @@ -310,6 +415,8 @@ export const useRtcConnection = ({ }, [ sdpOffer, inputAudioStream, + inputVideoStream, + videoEnabled, sessionId, joinProductionOptions, rtcPeerConnection, @@ -319,5 +426,5 @@ export const useRtcConnection = ({ useRtcDebugLogger(rtcPeerConnection); - return { connectionState, audioElements }; + return { connectionState, audioElements, videoElements }; }; diff --git a/src/components/production-line/use-shadow-rtc-connection.test.ts b/src/components/production-line/use-shadow-rtc-connection.test.ts new file mode 100644 index 00000000..ea04521d --- /dev/null +++ b/src/components/production-line/use-shadow-rtc-connection.test.ts @@ -0,0 +1,662 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + afterAll, +} from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { API } from "../../api/api.ts"; +import logger from "../../utils/logger.ts"; +import { TJoinProductionOptions } from "./types.ts"; +import { waitForIceGathering } from "./ice-gathering.ts"; +import { createAudioElement } from "./audio-element-factory.ts"; +import { createVideoElement } from "./video-element-factory.ts"; + +const rtcInstances: FakeRTCPeerConnection[] = []; + +class FakeRTCPeerConnection extends EventTarget { + config: RTCConfiguration | undefined; + + constructor(config?: RTCConfiguration) { + super(); + this.config = config; + rtcInstances.push(this); + } + + iceGatheringState: RTCIceGatheringState = "complete"; + + close = vi.fn(); + + addTrack = vi.fn(); + + setRemoteDescription = vi.fn().mockResolvedValue(undefined); + + createAnswer = vi + .fn() + .mockResolvedValue({ sdp: "v=0\r\nanswer", type: "answer" }); + + setLocalDescription = vi.fn().mockResolvedValue(undefined); +} + +vi.stubGlobal("RTCPeerConnection", FakeRTCPeerConnection); + +const hadGetTracks = "getTracks" in MediaStream.prototype; +if (!hadGetTracks) { + Object.defineProperty(MediaStream.prototype, "getTracks", { + configurable: true, + value(this: MediaStream) { + return [...this.getAudioTracks(), ...this.getVideoTracks()]; + }, + }); +} + +vi.mock("../../api/api.ts", () => ({ + API: { + offerAudioSession: vi.fn(), + patchAudioSession: vi.fn(), + deleteAudioSession: vi.fn(), + heartbeat: vi.fn(), + }, +})); + +vi.mock("../../utils/logger.ts", () => ({ + default: { + red: vi.fn(), + cyan: vi.fn(), + log: vi.fn(), + }, +})); + +vi.mock("./ice-gathering.ts", () => ({ + waitForIceGathering: vi.fn(), +})); + +vi.mock("./audio-element-factory.ts", () => ({ + createAudioElement: vi.fn(), +})); + +vi.mock("./video-element-factory.ts", () => ({ + createVideoElement: vi.fn(), + stopFrameMonitor: vi.fn(), +})); + +// eslint-disable-next-line import/first +import { useShadowRtcConnection } from "./use-shadow-rtc-connection.ts"; + +const mockOffer = API.offerAudioSession as ReturnType; +const mockPatch = API.patchAudioSession as ReturnType; +const mockDelete = API.deleteAudioSession as ReturnType; +const mockHeartbeat = API.heartbeat as ReturnType; +const mockIce = waitForIceGathering as ReturnType; +const mockCreateAudio = createAudioElement as ReturnType; +const mockCreateVideo = createVideoElement as ReturnType; +const mockLoggerRed = logger.red as ReturnType; + +const makeOptions = ( + overrides: Partial = {} +): TJoinProductionOptions => ({ + productionId: "7", + lineId: "3", + username: "shadow-user", + lineUsedForProgramOutput: true, + isProgramUser: false, + ...overrides, +}); + +type FakeTrack = EventTarget & { + kind: "audio" | "video"; + id: string; + readyState: MediaStreamTrackState; +}; + +const makeTrack = (kind: "audio" | "video", id: string): FakeTrack => + Object.assign(new EventTarget(), { + kind, + id, + readyState: "live" as MediaStreamTrackState, + }); + +const asTrack = (t: FakeTrack) => t as unknown as MediaStreamTrack; + +const fireTrack = ( + pc: FakeRTCPeerConnection, + track: FakeTrack, + streams: MediaStream[] +) => { + act(() => { + pc.dispatchEvent(Object.assign(new Event("track"), { track, streams })); + }); +}; + +const deferred = () => { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const flush = async () => { + await act(async () => { + for (let i = 0; i < 10; i += 1) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } + }); +}; + +type Opts = TJoinProductionOptions | null; + +const renderShadow = (initial: Opts) => + renderHook((opts: Opts) => useShadowRtcConnection(opts), { + initialProps: initial, + }); + +describe("useShadowRtcConnection", () => { + beforeEach(() => { + vi.clearAllMocks(); + rtcInstances.length = 0; + + mockOffer.mockResolvedValue({ sdp: "v=0\r\noffer", sessionId: "shadow-1" }); + mockPatch.mockResolvedValue(undefined); + mockDelete.mockResolvedValue("ok"); + mockHeartbeat.mockResolvedValue("ok"); + mockIce.mockResolvedValue(undefined); + + mockCreateAudio.mockImplementation( + ({ stream }: { stream: MediaStream }) => { + const el = document.createElement("audio"); + el.srcObject = stream; + vi.spyOn(el, "pause"); + return el; + } + ); + mockCreateVideo.mockImplementation( + ({ stream }: { stream: MediaStream }) => { + const el = document.createElement("video"); + el.srcObject = stream; + vi.spyOn(el, "pause"); + return el; + } + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + afterAll(() => { + if (!hadGetTracks) { + delete (MediaStream.prototype as Partial).getTracks; + } + }); + + describe("no options", () => { + it("is a no-op when options are null", async () => { + const { result } = renderShadow(null); + await flush(); + + expect(mockOffer).not.toHaveBeenCalled(); + expect(rtcInstances).toHaveLength(0); + expect(result.current.audioElements).toEqual([]); + expect(result.current.videoElements).toEqual([]); + }); + + it("does nothing on unmount when it never connected", async () => { + const { unmount } = renderShadow(null); + await flush(); + unmount(); + expect(mockDelete).not.toHaveBeenCalled(); + }); + }); + + describe("invalid ids", () => { + it.each([ + ["productionId", { productionId: "abc" }], + ["lineId", { lineId: "not-a-number" }], + ["empty productionId", { productionId: "" }], + ])( + "logs and does not request an offer when %s is NaN", + async (_label, overrides) => { + renderShadow(makeOptions(overrides)); + await flush(); + + expect(mockOffer).not.toHaveBeenCalled(); + expect(rtcInstances).toHaveLength(0); + expect(mockLoggerRed).toHaveBeenCalledTimes(1); + expect(mockLoggerRed).toHaveBeenCalledWith( + expect.stringContaining("invalid") + ); + } + ); + }); + + describe("happy path", () => { + it("negotiates offer → remote → answer → local → ICE → patch in order", async () => { + renderShadow(makeOptions()); + await flush(); + + expect(mockOffer).toHaveBeenCalledWith({ + productionId: 7, + lineId: 3, + username: "shadow-user", + }); + expect(rtcInstances).toHaveLength(1); + const pc = rtcInstances[0]; + + expect(pc.setRemoteDescription).toHaveBeenCalledWith({ + sdp: "v=0\r\noffer", + type: "offer", + }); + expect(pc.setLocalDescription).toHaveBeenCalledWith({ + sdp: "v=0\r\nanswer", + type: "answer", + }); + expect(mockIce).toHaveBeenCalledWith(pc); + expect(mockPatch).toHaveBeenCalledWith({ + sessionId: "shadow-1", + sdpAnswer: "v=0\r\nanswer", + }); + + const order = [ + mockOffer.mock.invocationCallOrder[0], + pc.setRemoteDescription.mock.invocationCallOrder[0], + pc.createAnswer.mock.invocationCallOrder[0], + pc.setLocalDescription.mock.invocationCallOrder[0], + mockIce.mock.invocationCallOrder[0], + mockPatch.mock.invocationCallOrder[0], + ]; + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); + + it("creates a receive-only peer connection (no local tracks) with STUN servers", async () => { + renderShadow(makeOptions()); + await flush(); + + const pc = rtcInstances[0]; + expect(pc.addTrack).not.toHaveBeenCalled(); + expect(pc.config?.iceServers?.length).toBeGreaterThan(0); + }); + + it("sends a heartbeat every 10s after the answer is patched", async () => { + vi.useFakeTimers(); + renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockPatch).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(9999); + }); + expect(mockHeartbeat).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockHeartbeat).toHaveBeenCalledTimes(1); + expect(mockHeartbeat).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + expect(mockHeartbeat).toHaveBeenCalledTimes(3); + }); + + it("logs heartbeat failures without stopping the interval", async () => { + vi.useFakeTimers(); + mockHeartbeat.mockRejectedValue(new Error("boom")); + renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(mockLoggerRed).toHaveBeenCalledWith( + expect.stringContaining("Shadow heartbeat error for session shadow-1") + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(mockHeartbeat).toHaveBeenCalledTimes(2); + }); + }); + + describe("failures", () => { + it("logs when negotiation fails and does not patch or heartbeat", async () => { + vi.useFakeTimers(); + mockIce.mockRejectedValueOnce(new Error("ice exploded")); + + renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + + expect(mockPatch).not.toHaveBeenCalled(); + expect(mockHeartbeat).not.toHaveBeenCalled(); + expect(mockLoggerRed).toHaveBeenCalledWith( + expect.stringContaining("Shadow RTC connection failed") + ); + }); + + it("still deletes the session on unmount after a failed negotiation", async () => { + mockIce.mockRejectedValueOnce(new Error("ice exploded")); + const { unmount } = renderShadow(makeOptions()); + await flush(); + + unmount(); + + expect(rtcInstances[0].close).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + }); + + it("logs when the offer request itself fails", async () => { + mockOffer.mockRejectedValueOnce(new Error("503")); + renderShadow(makeOptions()); + await flush(); + + expect(rtcInstances).toHaveLength(0); + expect(mockLoggerRed).toHaveBeenCalledWith( + expect.stringContaining("Shadow RTC connection failed") + ); + }); + }); + + describe("teardown", () => { + it("closes the PC, stops the heartbeat and deletes the session on unmount", async () => { + vi.useFakeTimers(); + const { unmount } = renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + const pc = rtcInstances[0]; + + unmount(); + + expect(pc.close).toHaveBeenCalledTimes(1); + expect(mockDelete).toHaveBeenCalledTimes(1); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockHeartbeat).not.toHaveBeenCalled(); + }); + + it("tears down the old session and opens a new one when options change", async () => { + mockOffer + .mockResolvedValueOnce({ sdp: "v=0\r\noffer", sessionId: "shadow-1" }) + .mockResolvedValueOnce({ sdp: "v=0\r\noffer", sessionId: "shadow-2" }); + + const { rerender } = renderShadow(makeOptions({ lineId: "3" })); + await flush(); + const firstPc = rtcInstances[0]; + + rerender(makeOptions({ lineId: "4" })); + await flush(); + + expect(firstPc.close).toHaveBeenCalledTimes(1); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + expect(mockOffer).toHaveBeenLastCalledWith( + expect.objectContaining({ lineId: 4 }) + ); + expect(rtcInstances).toHaveLength(2); + expect(rtcInstances[1].close).not.toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalledWith({ sessionId: "shadow-2" }); + }); + + it("tears down when options switch to null", async () => { + const { rerender } = renderShadow(makeOptions()); + await flush(); + + rerender(null); + + expect(rtcInstances[0].close).toHaveBeenCalled(); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + }); + + it("deletes the session if aborted before the offer resolves", async () => { + const offer = deferred<{ sdp: string; sessionId: string }>(); + mockOffer.mockReturnValueOnce(offer.promise); + + const { unmount } = renderShadow(makeOptions()); + await flush(); + expect(mockOffer).toHaveBeenCalledTimes(1); + + unmount(); + expect(mockDelete).not.toHaveBeenCalled(); + + await act(async () => { + offer.resolve({ sdp: "v=0\r\noffer", sessionId: "late-1" }); + }); + await flush(); + + expect(mockDelete).toHaveBeenCalledTimes(1); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "late-1" }); + expect(rtcInstances).toHaveLength(0); + expect(mockPatch).not.toHaveBeenCalled(); + }); + + it("does not patch or start a heartbeat if aborted during ICE gathering", async () => { + vi.useFakeTimers(); + const ice = deferred(); + mockIce.mockReturnValueOnce(ice.promise); + + const { unmount } = renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockIce).toHaveBeenCalled(); + + unmount(); + expect(mockDelete).toHaveBeenCalledWith({ sessionId: "shadow-1" }); + + await act(async () => { + ice.resolve(); + await vi.advanceTimersByTimeAsync(30000); + }); + + expect(mockPatch).not.toHaveBeenCalled(); + expect(mockHeartbeat).not.toHaveBeenCalled(); + expect(mockLoggerRed).not.toHaveBeenCalled(); + }); + + it("does not start a heartbeat if aborted while the patch is in flight", async () => { + vi.useFakeTimers(); + const patch = deferred(); + mockPatch.mockReturnValueOnce(patch.promise); + + const { unmount } = renderShadow(makeOptions()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockPatch).toHaveBeenCalled(); + + unmount(); + + await act(async () => { + patch.resolve(); + await vi.advanceTimersByTimeAsync(30000); + }); + expect(mockHeartbeat).not.toHaveBeenCalled(); + }); + + it("pauses and clears media elements on teardown", async () => { + const { result, rerender } = renderShadow(makeOptions()); + await flush(); + const pc = rtcInstances[0]; + + const audioTrack = makeTrack("audio", "a1"); + const videoTrack = makeTrack("video", "v1"); + fireTrack(pc, audioTrack, [new MediaStream([asTrack(audioTrack)])]); + fireTrack(pc, videoTrack, [new MediaStream([asTrack(videoTrack)])]); + + const [audioEl] = result.current.audioElements; + const [videoEl] = result.current.videoElements; + expect(audioEl).toBeDefined(); + expect(videoEl).toBeDefined(); + + rerender(null); + + expect(result.current.audioElements).toEqual([]); + expect(result.current.videoElements).toEqual([]); + expect(audioEl.pause).toHaveBeenCalled(); + expect(audioEl.srcObject).toBeNull(); + expect(videoEl.pause).toHaveBeenCalled(); + expect(videoEl.srcObject).toBeNull(); + }); + + it("ignores track events that arrive after teardown", async () => { + const { rerender } = renderShadow(makeOptions()); + await flush(); + const pc = rtcInstances[0]; + + rerender(null); + + const track = makeTrack("audio", "a1"); + fireTrack(pc, track, [new MediaStream([asTrack(track)])]); + expect(mockCreateAudio).not.toHaveBeenCalled(); + }); + }); + + describe("track events", () => { + const connect = async () => { + const hook = renderShadow(makeOptions()); + await flush(); + return { ...hook, pc: rtcInstances[0] }; + }; + + it("creates an audio element for an incoming audio track", async () => { + const { result, pc } = await connect(); + const track = makeTrack("audio", "a1"); + const stream = new MediaStream([asTrack(track)]); + + fireTrack(pc, track, [stream]); + + expect(mockCreateAudio).toHaveBeenCalledWith( + expect.objectContaining({ stream, lineId: "3", audiooutput: undefined }) + ); + expect(result.current.audioElements).toHaveLength(1); + expect(result.current.audioElements[0].srcObject).toBe(stream); + }); + + it("ignores an audio track that arrives without a stream", async () => { + const { result, pc } = await connect(); + fireTrack(pc, makeTrack("audio", "a1"), []); + + expect(mockCreateAudio).not.toHaveBeenCalled(); + expect(result.current.audioElements).toEqual([]); + }); + + it("creates a video element for an incoming video track using the stream id as endpointId", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + const stream = new MediaStream([asTrack(track)]); + + fireTrack(pc, track, [stream]); + + expect(mockCreateVideo).toHaveBeenCalledWith({ + stream, + lineId: "3", + endpointId: stream.id, + }); + expect(result.current.videoElements).toHaveLength(1); + }); + + it("wraps a stream-less video track in a new MediaStream (forwarder relay mode)", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + + fireTrack(pc, track, []); + + expect(mockCreateVideo).toHaveBeenCalledTimes(1); + const arg = mockCreateVideo.mock.calls[0][0] as { + stream: MediaStream; + endpointId: string | null; + }; + expect(arg.stream).toBeInstanceOf(MediaStream); + expect(arg.stream.getTracks()).toContain(track); + expect(arg.endpointId).toBeNull(); + expect(result.current.videoElements).toHaveLength(1); + }); + + it("dedupes a video track fired twice with the same stream", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + const stream = new MediaStream([asTrack(track)]); + + fireTrack(pc, track, [stream]); + fireTrack(pc, track, [stream]); + + expect(result.current.videoElements).toHaveLength(1); + }); + + it("dedupes a video track re-announced on a different stream with the same track id", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + + fireTrack(pc, track, [new MediaStream([asTrack(track)])]); + fireTrack(pc, track, [new MediaStream([asTrack(track)])]); + + expect(result.current.videoElements).toHaveLength(1); + }); + + it("keeps distinct video tracks as separate tiles, newest first", async () => { + const { result, pc } = await connect(); + const t1 = makeTrack("video", "v1"); + const t2 = makeTrack("video", "v2"); + const s1 = new MediaStream([asTrack(t1)]); + const s2 = new MediaStream([asTrack(t2)]); + + fireTrack(pc, t1, [s1]); + fireTrack(pc, t2, [s2]); + + expect(result.current.videoElements).toHaveLength(2); + expect(result.current.videoElements[0].srcObject).toBe(s2); + expect(result.current.videoElements[1].srcObject).toBe(s1); + }); + + it("removes the video tile when the remote track ends", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + fireTrack(pc, track, [new MediaStream([asTrack(track)])]); + const [el] = result.current.videoElements; + + act(() => { + track.dispatchEvent(new Event("ended")); + }); + + expect(result.current.videoElements).toEqual([]); + expect(el.pause).toHaveBeenCalled(); + expect(el.srcObject).toBeNull(); + }); + + it("removes the video tile when its track is removed from the stream", async () => { + const { result, pc } = await connect(); + const track = makeTrack("video", "v1"); + const stream = new MediaStream([asTrack(track)]); + fireTrack(pc, track, [stream]); + expect(result.current.videoElements).toHaveLength(1); + + act(() => { + stream.dispatchEvent( + Object.assign(new Event("removetrack"), { + track: { id: "someone-else" }, + }) + ); + }); + expect(result.current.videoElements).toHaveLength(1); + + act(() => { + stream.dispatchEvent( + Object.assign(new Event("removetrack"), { track }) + ); + }); + expect(result.current.videoElements).toEqual([]); + }); + }); +}); diff --git a/src/components/production-line/use-shadow-rtc-connection.ts b/src/components/production-line/use-shadow-rtc-connection.ts new file mode 100644 index 00000000..9b905b81 --- /dev/null +++ b/src/components/production-line/use-shadow-rtc-connection.ts @@ -0,0 +1,194 @@ +/** + * Self-contained hook that manages a "shadow" WebRTC session for a sibling + * program-output line without touching global state, dispatch, or navigation. + * + * Used by ProductionLine when the user joins a videoEnabled line whose + * production also contains a programOutputLine — the shadow connection + * receives the WHIP return feed on that line and surfaces it as plain + * audioElements / videoElements so the UI can render the Return Feed section. + */ + +import { useEffect } from "react"; +import { noop } from "../../helpers.ts"; +import { API } from "../../api/api.ts"; +import logger from "../../utils/logger.ts"; +import { TJoinProductionOptions } from "./types.ts"; +import { useAudioElements } from "./use-audio-elements.ts"; +import { useVideoElements } from "./use-video-elements.ts"; +import { createAudioElement } from "./audio-element-factory.ts"; +import { createVideoElement } from "./video-element-factory.ts"; +import { waitForIceGathering } from "./ice-gathering.ts"; + +export const useShadowRtcConnection = ( + shadowOptions: TJoinProductionOptions | null +): { audioElements: HTMLAudioElement[]; videoElements: HTMLVideoElement[] } => { + const { audioElements, setAudioElements, cleanUpAudio } = useAudioElements(); + const { videoElements, setVideoElements, cleanUpVideo } = useVideoElements(); + + useEffect(() => { + if (!shadowOptions) return noop; + + const { productionId, lineId, username } = shadowOptions; + let aborted = false; + let rtcPeerConnection: RTCPeerConnection | null = null; + let heartbeatInterval: number | null = null; + let sessionIdForCleanup: string | null = null; + + const teardown = () => { + aborted = true; + + if (heartbeatInterval !== null) { + window.clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + if (rtcPeerConnection) { + rtcPeerConnection.close(); + rtcPeerConnection = null; + } + + if (sessionIdForCleanup) { + API.deleteAudioSession({ sessionId: sessionIdForCleanup }).catch( + logger.red + ); + sessionIdForCleanup = null; + } + + // Pause and clear all managed media elements so they don't linger when + // shadowOptions changes (e.g. user leaves line). + cleanUpAudio(); + setAudioElements([]); + cleanUpVideo(); + setVideoElements([]); + }; + + const connect = async () => { + const productionIdNum = parseInt(productionId, 10); + const lineIdNum = parseInt(lineId, 10); + if (Number.isNaN(productionIdNum) || Number.isNaN(lineIdNum)) { + logger.red( + `Shadow RTC: invalid productionId "${productionId}" or lineId "${lineId}"` + ); + return; + } + + // 1. Request an SDP offer for the shadow line. + const { sdp: sdpOffer, sessionId } = await API.offerAudioSession({ + productionId: productionIdNum, + lineId: lineIdNum, + username, + }); + + if (aborted) { + API.deleteAudioSession({ sessionId }).catch(logger.red); + return; + } + + sessionIdForCleanup = sessionId; + + // 2. Create the peer connection (receive-only — no local tracks added). + rtcPeerConnection = new RTCPeerConnection({ + iceServers: [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + ], + }); + + // 3. Handle incoming tracks from the WHIP publisher. + rtcPeerConnection.addEventListener( + "track", + ({ streams, track }: RTCTrackEvent) => { + if (aborted) return; + + if (track.kind === "audio") { + const stream = streams[0]; + if (!stream) return; + const el = createAudioElement({ + stream, + lineId, + audiooutput: undefined, + onError: (e) => logger.red(`Shadow audio error: ${e.message}`), + onSinkError: (e) => logger.red(`Shadow sink error: ${e.message}`), + }); + setAudioElements((prev) => [el, ...prev]); + } else if (track.kind === "video") { + // In forwarder relay mode streams[0] can be undefined; wrap the + // track in a new MediaStream in that case. + const stream = streams[0] ?? new MediaStream([track]); + setVideoElements((prev) => { + // Deduplicate: skip if we already have this stream or track. + if ( + prev.some( + (el) => + el.srcObject === stream || + (el.srcObject instanceof MediaStream && + el.srcObject.getTracks().some((t) => t.id === track.id)) + ) + ) { + return prev; + } + + const endpointId = streams[0]?.id ?? null; + const el = createVideoElement({ stream, lineId, endpointId }); + + // Remove tile when the remote track ends. + const remove = () => { + setVideoElements((cur) => cur.filter((e) => e !== el)); + el.pause(); + el.srcObject = null; + }; + + track.addEventListener("ended", remove); + + if (stream instanceof MediaStream) { + stream.addEventListener("removetrack", (ev) => { + if (ev.track.id === track.id) remove(); + }); + } + + return [el, ...prev]; + }); + } + } + ); + + // 4. Negotiate: set remote offer, create answer, set local description. + await rtcPeerConnection.setRemoteDescription({ + sdp: sdpOffer, + type: "offer", + }); + + const answer = await rtcPeerConnection.createAnswer(); + if (!answer.sdp) throw new Error("No sdp in shadow answer"); + await rtcPeerConnection.setLocalDescription(answer); + + // 5. Wait for ICE candidates to be gathered. + await waitForIceGathering(rtcPeerConnection); + + if (aborted) return; + + // 6. Send the completed answer to the backend. + await API.patchAudioSession({ sessionId, sdpAnswer: answer.sdp }); + + if (aborted) return; + + // 7. Start heartbeat to keep the session alive. + heartbeatInterval = window.setInterval(() => { + API.heartbeat({ sessionId }).catch((err) => { + logger.red(`Shadow heartbeat error for session ${sessionId}: ${err}`); + }); + }, 10_000); + }; + + connect().catch((err) => { + if (!aborted) { + logger.red(`Shadow RTC connection failed: ${err}`); + } + }); + + return teardown; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shadowOptions]); + + return { audioElements, videoElements }; +}; diff --git a/src/components/production-line/use-update-call-device.tsx b/src/components/production-line/use-update-call-device.tsx index 10da74ad..32f219e3 100644 --- a/src/components/production-line/use-update-call-device.tsx +++ b/src/components/production-line/use-update-call-device.tsx @@ -25,75 +25,61 @@ export const useUpdateCallDevice = ({ const [{ userSettings }, dispatch] = useGlobalState(); useEffect(() => { - if (!isBrowserFirefox) { + if (!isBrowserFirefox && joinProductionOptions && audioElements) { const audioInputTheSame = - joinProductionOptions?.audioinput === userSettings?.audioinput; + joinProductionOptions.audioinput === userSettings?.audioinput; const audioOutputTheSame = audiooutput === userSettings?.audiooutput; - const payload = { - joinProductionOptions: { - productionId: joinProductionOptions?.productionId, - lineId: joinProductionOptions?.lineId, - username: joinProductionOptions?.username, - audioinput: userSettings?.audioinput, - lineUsedForProgramOutput: - joinProductionOptions?.lineUsedForProgramOutput, - isProgramUser: joinProductionOptions?.isProgramUser, - }, - audiooutput: userSettings?.audiooutput || audiooutput, - }; + const newAudiooutput = userSettings?.audiooutput || audiooutput; - // Check if all payload properties are defined - const allPropertiesDefined = Object.values( - payload.joinProductionOptions - ).every((v) => v !== undefined); + if (audioInputTheSame && audioOutputTheSame) return; - if ((!audioInputTheSame || !audioOutputTheSame) && audioElements) { - if (audioInputTheSame && !audioOutputTheSame) { - audioElements.forEach((audioElement) => { - audioElement.setSinkId(payload.audiooutput || ""); - }); - dispatch({ - type: "UPDATE_CALL", - payload: { - id, - updates: { - audiooutput: payload.audiooutput, - }, + if (audioInputTheSame && !audioOutputTheSame) { + audioElements.forEach((audioElement) => { + audioElement.setSinkId(newAudiooutput || ""); + }); + dispatch({ + type: "UPDATE_CALL", + payload: { + id, + updates: { + audiooutput: newAudiooutput, }, - }); - } else if (allPropertiesDefined) { - setConnectionActive(false); - resetAudioInput(); - muteInput(true); + }, + }); + } else if ( + userSettings?.audioinput && + joinProductionOptions.productionId && + joinProductionOptions.lineId && + joinProductionOptions.username + ) { + setConnectionActive(false); + resetAudioInput(); + muteInput(true); - // Create a non-nullable version of the payload for TypeScript - const safePayload = { - productionId: payload.joinProductionOptions.productionId!, - lineId: payload.joinProductionOptions.lineId!, - username: payload.joinProductionOptions.username!, - audioinput: payload.joinProductionOptions.audioinput!, - lineUsedForProgramOutput: - payload.joinProductionOptions.lineUsedForProgramOutput!, - isProgramUser: payload.joinProductionOptions.isProgramUser!, - }; + // Spread the existing joinProductionOptions to preserve all + // fields (videoinput, videoEnabled, lineName, productionName) + // — otherwise a mic change drops the video track on reconnect. + const newJoinProductionOptions = { + ...joinProductionOptions, + audioinput: userSettings.audioinput, + }; - dispatch({ - type: "UPDATE_CALL", - payload: { - id, - updates: { - joinProductionOptions: safePayload, - audiooutput: payload.audiooutput, - mediaStreamInput: null, - dominantSpeaker: null, - audioLevelAboveThreshold: false, - connectionState: null, - audioElements: null, - sessionId: null, - }, + dispatch({ + type: "UPDATE_CALL", + payload: { + id, + updates: { + joinProductionOptions: newJoinProductionOptions, + audiooutput: newAudiooutput, + mediaStreamInput: null, + dominantSpeaker: null, + audioLevelAboveThreshold: false, + connectionState: null, + audioElements: null, + sessionId: null, }, - }); - } + }, + }); } } }, [ diff --git a/src/components/production-line/use-video-elements.test.ts b/src/components/production-line/use-video-elements.test.ts new file mode 100644 index 00000000..e82537d4 --- /dev/null +++ b/src/components/production-line/use-video-elements.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +const stopFrameMonitor = vi.fn(); + +vi.mock("./video-element-factory.ts", () => ({ + stopFrameMonitor: (el: HTMLVideoElement) => stopFrameMonitor(el), +})); + +// eslint-disable-next-line import/first +import { useVideoElements } from "./use-video-elements.ts"; + +describe("useVideoElements", () => { + beforeEach(() => { + stopFrameMonitor.mockClear(); + }); + + it("starts with an empty array", () => { + const { result } = renderHook(() => useVideoElements()); + + expect(result.current.videoElements).toEqual([]); + }); + + it("cleanUpVideo stops frame monitors, pauses and nulls srcObject", () => { + const { result } = renderHook(() => useVideoElements()); + + const el1 = { + pause: vi.fn(), + srcObject: "stream1", + } as unknown as HTMLVideoElement; + const el2 = { + pause: vi.fn(), + srcObject: "stream2", + } as unknown as HTMLVideoElement; + + act(() => { + result.current.setVideoElements([el1, el2]); + }); + + act(() => { + result.current.cleanUpVideo(); + }); + + expect(stopFrameMonitor).toHaveBeenCalledWith(el1); + expect(stopFrameMonitor).toHaveBeenCalledWith(el2); + expect(el1.pause).toHaveBeenCalled(); + expect(el2.pause).toHaveBeenCalled(); + expect(el1.srcObject).toBeNull(); + expect(el2.srcObject).toBeNull(); + }); + + it("stops frame monitors for every element on unmount", () => { + const el = { + pause: vi.fn(), + srcObject: "stream", + } as unknown as HTMLVideoElement; + + const { result, unmount } = renderHook(() => useVideoElements()); + + act(() => { + result.current.setVideoElements([el]); + }); + + unmount(); + + expect(stopFrameMonitor).toHaveBeenCalledWith(el); + expect(el.pause).toHaveBeenCalled(); + expect(el.srcObject).toBeNull(); + }); +}); diff --git a/src/components/production-line/use-video-elements.ts b/src/components/production-line/use-video-elements.ts new file mode 100644 index 00000000..4c284144 --- /dev/null +++ b/src/components/production-line/use-video-elements.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { stopFrameMonitor } from "./video-element-factory.ts"; + +export const useVideoElements = () => { + const [videoElements, setVideoElements] = useState([]); + const videoElementsRef = useRef(videoElements); + + useEffect(() => { + videoElementsRef.current = videoElements; + }, [videoElements]); + + const cleanUpVideo = useCallback(() => { + videoElementsRef.current.forEach((el) => { + stopFrameMonitor(el); + el.pause(); + // eslint-disable-next-line no-param-reassign + el.srcObject = null; + }); + }, [videoElementsRef]); + + useEffect( + () => () => { + cleanUpVideo(); + }, + [cleanUpVideo] + ); + + return { videoElements, setVideoElements, cleanUpVideo }; +}; diff --git a/src/components/production-line/use-video-input.test.ts b/src/components/production-line/use-video-input.test.ts new file mode 100644 index 00000000..020636f3 --- /dev/null +++ b/src/components/production-line/use-video-input.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useVideoInput } from "./use-video-input.ts"; + +const makeTrack = () => ({ stop: vi.fn(), kind: "video" }); + +describe("useVideoInput", () => { + const dispatch = vi.fn(); + + beforeEach(() => { + dispatch.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reset stops all camera tracks and clears the stream", async () => { + const tracks = [makeTrack(), makeTrack()]; + const stream = { + getTracks: () => tracks, + } as unknown as MediaStream; + + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn().mockResolvedValue(stream), + }, + }); + + const { result } = renderHook(() => + useVideoInput({ videoInputId: "cam-1", callId: "call-1", dispatch }) + ); + + await waitFor(() => { + expect(result.current[0]).toBe(stream); + }); + + act(() => { + // reset is the third tuple element + result.current[2](); + }); + + tracks.forEach((t) => { + expect(t.stop).toHaveBeenCalledTimes(1); + }); + expect(result.current[0]).toBeNull(); + }); + + it("reset is a no-op when there is no live stream", () => { + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(), + }, + }); + + const { result } = renderHook(() => + useVideoInput({ videoInputId: "no-device", callId: "call-1", dispatch }) + ); + + expect(() => { + act(() => { + result.current[2](); + }); + }).not.toThrow(); + expect(result.current[0]).toBeNull(); + }); +}); diff --git a/src/components/production-line/use-video-input.ts b/src/components/production-line/use-video-input.ts new file mode 100644 index 00000000..8b89f6a9 --- /dev/null +++ b/src/components/production-line/use-video-input.ts @@ -0,0 +1,88 @@ +import { Dispatch, useCallback, useEffect, useState } from "react"; +import { noop } from "../../helpers"; +import { TJoinProductionOptions } from "./types.ts"; +import { TGlobalStateAction } from "../../global-state/global-state-actions.ts"; +import logger from "../../utils/logger.ts"; + +type TGetVideoDeviceOptions = { + videoInputId: TJoinProductionOptions["videoinput"] | null; + callId: string; + dispatch: Dispatch; +}; + +export type TUseVideoInputValues = MediaStream | "no-device" | null; + +type TUseVideoInput = ( + options: TGetVideoDeviceOptions +) => [TUseVideoInputValues, boolean, () => void]; + +// A hook for fetching the user selected video input as a MediaStream +export const useVideoInput: TUseVideoInput = ({ + videoInputId, + callId, + dispatch, +}) => { + const [videoInput, setVideoInput] = useState(null); + const [videoInputError, setVideoInputError] = useState(false); + + useEffect(() => { + let aborted = false; + let acquired: MediaStream | null = null; + + if (videoInputId === null) { + setVideoInput("no-device"); + return noop; + } + + if (videoInputId === "no-device") { + setVideoInput("no-device"); + return noop; + } + + const qualityConstraints: MediaTrackConstraints = { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 25 }, + }; + const videoConstraints: MediaTrackConstraints = + videoInputId === "" + ? qualityConstraints + : { ...qualityConstraints, deviceId: { exact: videoInputId } }; + + navigator.mediaDevices + .getUserMedia({ video: videoConstraints }) + .then((stream) => { + if (aborted) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + acquired = stream; + setVideoInput(stream); + }) + .catch((err: unknown) => { + logger.red(`[useVideoInput] getUserMedia rejected: ${String(err)}`); + setVideoInputError(true); + dispatch({ + type: "ERROR", + payload: { + callId, + error: new Error("Selected camera is not available"), + }, + }); + }); + + return () => { + aborted = true; + if (acquired) acquired.getTracks().forEach((t) => t.stop()); + }; + }, [videoInputId, callId, dispatch]); + + const reset = useCallback(() => { + if (videoInput && videoInput !== "no-device") { + videoInput.getTracks().forEach((t) => t.stop()); + } + setVideoInput(null); + }, [videoInput]); + + return [videoInput, videoInputError, reset]; +}; diff --git a/src/components/production-line/use-video-source-pin.test.ts b/src/components/production-line/use-video-source-pin.test.ts new file mode 100644 index 00000000..b13117f7 --- /dev/null +++ b/src/components/production-line/use-video-source-pin.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { TJoinProductionOptions } from "./types.ts"; + +const setLineWhepSource = vi.fn(); +const setSessionVideoSource = vi.fn(); + +vi.mock("../../api/api.ts", () => ({ + API: { + setLineWhepSource: (...args: unknown[]) => setLineWhepSource(...args), + setSessionVideoSource: (...args: unknown[]) => + setSessionVideoSource(...args), + }, +})); + +vi.mock("../../utils/logger.ts", () => ({ + default: { red: vi.fn(), yellow: vi.fn() }, +})); + +// eslint-disable-next-line import/first +import { useVideoSourcePin } from "./use-video-source-pin.ts"; + +const joinProductionOptions = { + productionId: "1", + lineId: "2", + username: "user", +} as TJoinProductionOptions; + +const tooEarly = () => Object.assign(new Error("Too Early"), { status: 425 }); + +describe("useVideoSourcePin", () => { + beforeEach(() => { + setLineWhepSource.mockReset().mockResolvedValue({}); + setSessionVideoSource.mockReset().mockResolvedValue({}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("pushWhepSourceToBackend", () => { + it("sets the line WHEP source", () => { + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + result.current.pushWhepSourceToBackend("session-a"); + + expect(setLineWhepSource).toHaveBeenCalledWith({ + productionId: "1", + lineId: "2", + sessionId: "session-a", + }); + }); + + it("passes null to clear the WHEP source", () => { + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + result.current.pushWhepSourceToBackend(null); + + expect(setLineWhepSource).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: null }) + ); + }); + + it("ignores empty session ids and missing production options", () => { + const { result, rerender } = renderHook( + ({ options }) => + useVideoSourcePin({ + joinProductionOptions: options, + callSessionId: "self", + }), + { + initialProps: { + options: joinProductionOptions as TJoinProductionOptions | null, + }, + } + ); + + result.current.pushWhepSourceToBackend(""); + rerender({ options: null }); + result.current.pushWhepSourceToBackend("session-a"); + + expect(setLineWhepSource).not.toHaveBeenCalled(); + }); + + it("does not throw when the request fails", async () => { + setLineWhepSource.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + expect(() => + result.current.pushWhepSourceToBackend("session-a") + ).not.toThrow(); + await Promise.resolve(); + }); + }); + + describe("pushVideoSourceToBackend", () => { + it("sets the session video source", async () => { + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + await result.current.pushVideoSourceToBackend("session-a"); + + expect(setSessionVideoSource).toHaveBeenCalledWith({ + sessionId: "self", + pinnedSessionId: "session-a", + }); + }); + + it("does nothing without a call session or with an empty id", async () => { + const { result, rerender } = renderHook( + ({ callSessionId }) => + useVideoSourcePin({ joinProductionOptions, callSessionId }), + { initialProps: { callSessionId: "self" as string | null } } + ); + + await result.current.pushVideoSourceToBackend(""); + rerender({ callSessionId: null }); + await result.current.pushVideoSourceToBackend("session-a"); + + expect(setSessionVideoSource).not.toHaveBeenCalled(); + }); + + it("retries on 425 Too Early until it succeeds", async () => { + vi.useFakeTimers(); + setSessionVideoSource + .mockRejectedValueOnce(tooEarly()) + .mockRejectedValueOnce(tooEarly()) + .mockResolvedValueOnce({}); + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + let done = false; + act(() => { + result.current.pushVideoSourceToBackend("session-a").then(() => { + done = true; + }); + }); + await act(() => vi.runAllTimersAsync()); + + expect(done).toBe(true); + expect(setSessionVideoSource).toHaveBeenCalledTimes(3); + }); + + it("gives up after the last retry delay", async () => { + vi.useFakeTimers(); + setSessionVideoSource.mockRejectedValue(tooEarly()); + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + const pending = result.current.pushVideoSourceToBackend("session-a"); + await act(() => vi.runAllTimersAsync()); + await pending; + + expect(setSessionVideoSource).toHaveBeenCalledTimes(6); + }); + + it("does not retry on other errors", async () => { + setSessionVideoSource.mockRejectedValue( + Object.assign(new Error("Not Found"), { status: 404 }) + ); + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + await result.current.pushVideoSourceToBackend("session-a"); + + expect(setSessionVideoSource).toHaveBeenCalledTimes(1); + }); + + it("stops retrying an older pin once a newer one is requested", async () => { + vi.useFakeTimers(); + setSessionVideoSource + .mockRejectedValueOnce(tooEarly()) + .mockResolvedValue({}); + const { result } = renderHook(() => + useVideoSourcePin({ joinProductionOptions, callSessionId: "self" }) + ); + + const first = result.current.pushVideoSourceToBackend("session-a"); + await act(async () => { + await Promise.resolve(); + }); + const second = result.current.pushVideoSourceToBackend("session-b"); + await act(() => vi.runAllTimersAsync()); + await Promise.all([first, second]); + + const pinned = setSessionVideoSource.mock.calls.map( + ([args]) => args.pinnedSessionId + ); + expect(pinned).toEqual(["session-a", "session-b"]); + }); + }); +}); diff --git a/src/components/production-line/use-video-source-pin.ts b/src/components/production-line/use-video-source-pin.ts new file mode 100644 index 00000000..784a6156 --- /dev/null +++ b/src/components/production-line/use-video-source-pin.ts @@ -0,0 +1,88 @@ +import { useCallback, useRef } from "react"; +import { API } from "../../api/api.ts"; +import logger from "../../utils/logger.ts"; +import { TJoinProductionOptions } from "./types.ts"; + +const PIN_RETRY_DELAYS_MS = [300, 600, 1000, 1500, 2000]; + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +type UseVideoSourcePinArgs = { + joinProductionOptions: TJoinProductionOptions | null; + callSessionId: string | null; +}; + +type UseVideoSourcePinResult = { + pushWhepSourceToBackend: (nextSessionId: string | null) => void; + pushVideoSourceToBackend: (nextSessionId: string | null) => Promise; +}; + +export const useVideoSourcePin = ({ + joinProductionOptions, + callSessionId, +}: UseVideoSourcePinArgs): UseVideoSourcePinResult => { + const pushWhepSourceToBackend = useCallback( + (nextSessionId: string | null) => { + if ( + !joinProductionOptions?.productionId || + !joinProductionOptions?.lineId + ) { + return; + } + if (nextSessionId === "") { + return; + } + API.setLineWhepSource({ + productionId: joinProductionOptions.productionId, + lineId: joinProductionOptions.lineId, + sessionId: nextSessionId, + }).catch((err) => { + logger.red(`[useVideoSourcePin] setLineWhepSource failed: ${err}`); + }); + }, + [joinProductionOptions?.productionId, joinProductionOptions?.lineId] + ); + + const latestPinRequestRef = useRef(null); + + const pushVideoSourceToBackend = useCallback( + async (nextSessionId: string | null): Promise => { + if (!callSessionId) return; + if (nextSessionId === "") return; + + latestPinRequestRef.current = nextSessionId; + + for ( + let attempt = 0; + attempt <= PIN_RETRY_DELAYS_MS.length; + attempt += 1 + ) { + if (latestPinRequestRef.current !== nextSessionId) { + return; + } + try { + // eslint-disable-next-line no-await-in-loop + await API.setSessionVideoSource({ + sessionId: callSessionId, + pinnedSessionId: nextSessionId, + }); + return; + } catch (err) { + const status = (err as Error & { status?: number })?.status; + + if (status !== 425 || attempt === PIN_RETRY_DELAYS_MS.length) { + return; + } + // eslint-disable-next-line no-await-in-loop + await delay(PIN_RETRY_DELAYS_MS[attempt]); + } + } + }, + [callSessionId] + ); + + return { pushWhepSourceToBackend, pushVideoSourceToBackend }; +}; diff --git a/src/components/production-line/use-video-tile-grid.lifecycle.test.ts b/src/components/production-line/use-video-tile-grid.lifecycle.test.ts new file mode 100644 index 00000000..7f3a1aae --- /dev/null +++ b/src/components/production-line/use-video-tile-grid.lifecycle.test.ts @@ -0,0 +1,248 @@ +import { renderHook, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useVideoTileGrid } from "./use-video-tile-grid.ts"; +import { createVideoElement } from "./video-element-factory.ts"; +import { TParticipant } from "./types.ts"; + +const SELF = "self-session"; + +const participant = ( + overrides: Partial & { sessionId: string } +): TParticipant => ({ + name: `user-${overrides.sessionId}`, + endpointId: `ep-${overrides.sessionId}`, + isActive: true, + isWhip: false, + isWhepReceiver: false, + hasVideo: true, + ...overrides, +}); + +type FakeTrack = { muted: boolean; readyState: string }; + +const makeStream = (id: string): { stream: MediaStream; track: FakeTrack } => { + const track = { + kind: "video", + id: `${id}-track`, + readyState: "live", + muted: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + const stream = Object.create(MediaStream.prototype); + Object.assign(stream, { + id, + getTracks: () => [track], + getVideoTracks: () => [track], + addEventListener: () => {}, + removeEventListener: () => {}, + }); + return { stream: stream as MediaStream, track }; +}; + +const makeTile = (endpointId: string) => { + const { stream, track } = makeStream(endpointId); + const el = createVideoElement({ stream, lineId: "1", endpointId }); + Object.defineProperty(el, "videoWidth", { value: 640, configurable: true }); + Object.defineProperty(el, "videoHeight", { value: 360, configurable: true }); + return { el, track }; +}; + +const tick = (ms = 600) => { + act(() => { + vi.advanceTimersByTime(ms); + }); +}; + +const isVisible = (el: HTMLVideoElement) => + (el.parentElement as HTMLElement).style.display !== "none"; + +const match = (sessionId: string | null, name: string | null) => ({ + name, + isWhip: false, + sessionId, +}); + +const UNMATCHED = match(null, null); + +type HookProps = Parameters[0]; + +const baseProps = (over: Partial): HookProps => ({ + videoElements: [], + participants: [], + tileMatches: [], + pinnedContainer: null, + pinnedSessionId: null, + selfSessionId: SELF, + cameraStream: null, + cameraLabel: null, + pendingPinPromiseRef: { current: null }, + onAutoUnpin: () => {}, + ...over, +}); + +const renderGrid = (grid: HTMLDivElement, over: Partial) => { + const view = renderHook((p: HookProps) => useVideoTileGrid(p), { + initialProps: baseProps(over), + }); + act(() => { + view.result.current.setVideoGridEl(grid); + }); + return { + ...view, + update: (next: Partial) => view.rerender(baseProps(next)), + }; +}; + +describe("useVideoTileGrid — remote tile lifecycle", () => { + let grid: HTMLDivElement; + + beforeEach(() => { + vi.useFakeTimers(); + grid = document.createElement("div"); + document.body.appendChild(grid); + }); + + afterEach(() => { + vi.useRealTimers(); + grid.remove(); + }); + + it("shows a tile while its publisher is in the call", () => { + const { el } = makeTile("ep-a"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + + renderGrid(grid, { + videoElements: [el], + participants: [a], + tileMatches: [match("a", a.name)], + }); + tick(); + + expect(isVisible(el)).toBe(true); + expect(grid.contains(el.parentElement)).toBe(true); + }); + + it("hides the frozen tile when the last publisher leaves, without destroying it", () => { + const { el } = makeTile("ep-a"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + + const view = renderGrid(grid, { + videoElements: [el], + participants: [a], + tileMatches: [match("a", a.name)], + }); + tick(); + expect(isVisible(el)).toBe(true); + + view.update({ + videoElements: [el], + participants: [], + tileMatches: [UNMATCHED], + }); + + expect(isVisible(el)).toBe(false); + expect(grid.contains(el.parentElement)).toBe(true); + expect(el.srcObject).not.toBeNull(); + expect(el.paused).toBe(false); + }); + + it("keeps the tile hidden when the frozen element fires media events", () => { + const { el } = makeTile("ep-a"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + + const view = renderGrid(grid, { + videoElements: [el], + participants: [a], + tileMatches: [match("a", a.name)], + }); + tick(); + + view.update({ + videoElements: [el], + participants: [], + tileMatches: [UNMATCHED], + }); + expect(isVisible(el)).toBe(false); + + ["canplay", "playing", "resize", "loadedmetadata"].forEach((name) => { + act(() => { + el.dispatchEvent(new Event(name)); + }); + expect(isVisible(el)).toBe(false); + }); + tick(3000); + + expect(isVisible(el)).toBe(false); + }); + + it("brings the tile back when a new publisher takes over the slot", () => { + const { el } = makeTile("ep-a"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + + const view = renderGrid(grid, { + videoElements: [el], + participants: [a], + tileMatches: [match("a", a.name)], + }); + tick(); + + view.update({ + videoElements: [el], + participants: [], + tileMatches: [UNMATCHED], + }); + expect(isVisible(el)).toBe(false); + + const b = participant({ sessionId: "b", endpointId: "ep-b" }); + view.update({ + videoElements: [el], + participants: [b], + tileMatches: [UNMATCHED], + }); + + expect(isVisible(el)).toBe(true); + }); + + it("hides a tile whose publisher is still listed but has stopped sending", () => { + const { el, track } = makeTile("ep-a"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + + renderGrid(grid, { + videoElements: [el], + participants: [a], + tileMatches: [match("a", a.name)], + }); + tick(); + expect(isVisible(el)).toBe(true); + + track.muted = true; + tick(); + + expect(isVisible(el)).toBe(false); + }); + + it("leaves the surviving tile alone when one of two publishers leaves", () => { + const { el: elA } = makeTile("ep-a"); + const { el: elB } = makeTile("ep-b"); + const a = participant({ sessionId: "a", endpointId: "ep-a" }); + const b = participant({ sessionId: "b", endpointId: "ep-b" }); + + const view = renderGrid(grid, { + videoElements: [elA, elB], + participants: [a, b], + tileMatches: [match("a", a.name), match("b", b.name)], + }); + tick(); + + view.update({ + videoElements: [elA, elB], + participants: [b], + tileMatches: [UNMATCHED, match("b", b.name)], + }); + tick(); + + expect(isVisible(elB)).toBe(true); + expect(isVisible(elA)).toBe(false); + }); +}); diff --git a/src/components/production-line/use-video-tile-grid.test.ts b/src/components/production-line/use-video-tile-grid.test.ts new file mode 100644 index 00000000..9e6f4874 --- /dev/null +++ b/src/components/production-line/use-video-tile-grid.test.ts @@ -0,0 +1,758 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { MutableRefObject } from "react"; +import { TParticipant } from "./types.ts"; +import { LabelResult } from "./match-video-tile-labels.ts"; + +const setTilePinTransitioning = vi.fn(); +const setTilePipStream = vi.fn(); +const setTilePipLabel = vi.fn(); +const updateVideoTileLabel = vi.fn(); +const updateVideoTileContainerPinned = vi.fn(); +const setTileParticipantBacked = vi.fn(); + +vi.mock("./video-element-factory.ts", () => ({ + setTilePinTransitioning: (tile: HTMLElement, on: boolean) => + setTilePinTransitioning(tile, on), + setTilePipStream: (tile: HTMLElement, stream: MediaStream | null) => + setTilePipStream(tile, stream), + setTilePipLabel: (tile: HTMLElement, label: string | null) => + setTilePipLabel(tile, label), + updateVideoTileLabel: ( + el: HTMLVideoElement, + name: string | null, + isWhip: boolean + ) => updateVideoTileLabel(el, name, isWhip), + updateVideoTileContainerPinned: (tile: HTMLElement, isPinned: boolean) => + updateVideoTileContainerPinned(tile, isPinned), + setTileParticipantBacked: (el: HTMLVideoElement, backed: boolean) => + setTileParticipantBacked(el, backed), +})); + +// eslint-disable-next-line import/first +import { useVideoTileGrid } from "./use-video-tile-grid.ts"; + +type Tile = { tile: HTMLDivElement; video: HTMLVideoElement }; + +const makeTile = (id: string): Tile => { + const tile = document.createElement("div"); + tile.dataset.testid = id; + const video = document.createElement("video"); + tile.appendChild(video); + return { tile, video }; +}; + +const makeParticipant = ( + overrides: Partial = {} +): TParticipant => ({ + name: "Alice", + sessionId: "s1", + endpointId: "e1", + isActive: true, + isWhip: false, + hasVideo: true, + ...overrides, +}); + +const match = ( + sessionId: string | null, + name: string | null = null, + isWhip = false +): LabelResult => ({ sessionId, name, isWhip }); + +const makeTrack = (readyState: MediaStreamTrackState) => + ({ + id: `t-${Math.random()}`, + kind: "video", + readyState, + }) as unknown as MediaStreamTrack; + +type Props = Parameters[0]; + +const baseProps = (overrides: Partial = {}): Props => ({ + videoElements: [], + participants: [], + tileMatches: [], + pinnedContainer: null, + pinnedSessionId: null, + selfSessionId: null, + cameraStream: null, + cameraLabel: null, + pendingPinPromiseRef: { + current: null, + } as MutableRefObject | null>, + onAutoUnpin: vi.fn(), + ...overrides, +}); + +const renderGrid = (initial: Props) => { + const grid = document.createElement("div"); + const hook = renderHook((props: Props) => useVideoTileGrid(props), { + initialProps: initial, + }); + act(() => { + hook.result.current.setVideoGridEl(grid); + }); + return { ...hook, grid }; +}; + +const childIds = (grid: HTMLElement) => + Array.from(grid.children).map((c) => (c as HTMLElement).dataset.testid); + +describe("useVideoTileGrid", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("grid reconciliation", () => { + it("does not touch any grid until setVideoGridEl is called", () => { + const a = makeTile("a"); + const { result } = renderHook(() => + useVideoTileGrid(baseProps({ videoElements: [a.video] })) + ); + expect(typeof result.current.setVideoGridEl).toBe("function"); + expect(a.tile.parentElement).toBeNull(); + expect(updateVideoTileLabel).not.toHaveBeenCalled(); + }); + + it("tolerates videoElements being null", () => { + const { grid } = renderGrid(baseProps({ videoElements: null })); + expect(grid.children).toHaveLength(0); + }); + + it("appends each video's parent tile to the grid in order", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const c = makeTile("c"); + const { grid } = renderGrid( + baseProps({ videoElements: [a.video, b.video, c.video] }) + ); + expect(childIds(grid)).toEqual(["a", "b", "c"]); + }); + + it("falls back to the bare video element when it has no parent", () => { + const video = document.createElement("video"); + const { grid } = renderGrid(baseProps({ videoElements: [video] })); + expect(grid.firstChild).toBe(video); + }); + + it("removes grid children that are no longer backed by a video element", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const { grid, rerender } = renderGrid( + baseProps({ videoElements: [a.video, b.video] }) + ); + const stray = document.createElement("div"); + stray.dataset.testid = "stray"; + grid.appendChild(stray); + + rerender(baseProps({ videoElements: [b.video] })); + + expect(childIds(grid)).toEqual(["b"]); + }); + + it("reorders existing tiles to match the new videoElements order", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const c = makeTile("c"); + const { grid, rerender } = renderGrid( + baseProps({ videoElements: [a.video, b.video, c.video] }) + ); + + rerender(baseProps({ videoElements: [c.video, a.video, b.video] })); + + expect(childIds(grid)).toEqual(["c", "a", "b"]); + }); + + it("does not move tiles that are already in the right slot", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const { grid, rerender } = renderGrid( + baseProps({ videoElements: [a.video, b.video] }) + ); + const insertSpy = vi.spyOn(grid, "insertBefore"); + + rerender(baseProps({ videoElements: [a.video, b.video] })); + + expect(insertSpy).not.toHaveBeenCalled(); + expect(childIds(grid)).toEqual(["a", "b"]); + }); + }); + + describe("pinning", () => { + it("moves the pinned container to the front of the grid", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const c = makeTile("c"); + const { grid } = renderGrid( + baseProps({ + videoElements: [a.video, b.video, c.video], + pinnedContainer: c.tile, + }) + ); + expect(childIds(grid)).toEqual(["c", "a", "b"]); + }); + + it("marks only the pinned tile via updateVideoTileContainerPinned and style.order", () => { + const a = makeTile("a"); + const b = makeTile("b"); + renderGrid( + baseProps({ + videoElements: [a.video, b.video], + pinnedContainer: b.tile, + }) + ); + + expect(updateVideoTileContainerPinned).toHaveBeenCalledWith(b.tile, true); + expect(updateVideoTileContainerPinned).toHaveBeenCalledWith( + a.tile, + false + ); + expect(b.tile.style.order).toBe("-1"); + expect(a.tile.style.order).toBe("0"); + }); + + it("un-marks the previously pinned tile when the pin moves", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video, b.video], + pinnedContainer: a.tile, + }) + ); + updateVideoTileContainerPinned.mockClear(); + + rerender( + baseProps({ + videoElements: [a.video, b.video], + pinnedContainer: b.tile, + }) + ); + + expect(updateVideoTileContainerPinned).toHaveBeenCalledWith( + a.tile, + false + ); + expect(updateVideoTileContainerPinned).toHaveBeenCalledWith(b.tile, true); + expect(a.tile.style.order).toBe("0"); + expect(b.tile.style.order).toBe("-1"); + }); + + it("calls onAutoUnpin when the pinned container is no longer among the tiles", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const onAutoUnpin = vi.fn(); + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video, b.video], + pinnedContainer: b.tile, + onAutoUnpin, + }) + ); + expect(onAutoUnpin).not.toHaveBeenCalled(); + + rerender( + baseProps({ + videoElements: [a.video], + pinnedContainer: b.tile, + onAutoUnpin, + }) + ); + expect(onAutoUnpin).toHaveBeenCalledTimes(1); + }); + + it("does not call onAutoUnpin when nothing is pinned", () => { + const onAutoUnpin = vi.fn(); + renderGrid(baseProps({ videoElements: [], onAutoUnpin })); + expect(onAutoUnpin).not.toHaveBeenCalled(); + }); + }); + + describe("labels", () => { + it("labels each tile from its tileMatches entry", () => { + const a = makeTile("a"); + const b = makeTile("b"); + renderGrid( + baseProps({ + videoElements: [a.video, b.video], + tileMatches: [match(null, "Alice"), match(null, "Camera 1", true)], + participants: [makeParticipant()], + }) + ); + expect(updateVideoTileLabel).toHaveBeenCalledWith( + a.video, + "Alice", + false + ); + expect(updateVideoTileLabel).toHaveBeenCalledWith( + b.video, + "Camera 1", + true + ); + }); + + it("clears the label when there is no match", () => { + const a = makeTile("a"); + renderGrid(baseProps({ videoElements: [a.video], tileMatches: [] })); + expect(updateVideoTileLabel).toHaveBeenCalledWith(a.video, null, false); + }); + + it("prefers the pinned participant's name and WHIP flag over the match", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [ + makeParticipant({ sessionId: "s1", name: "Alice" }), + makeParticipant({ sessionId: "w1", name: "Feed", isWhip: true }), + ], + pinnedSessionId: "w1", + }) + ); + expect(updateVideoTileLabel).toHaveBeenLastCalledWith( + a.video, + "Feed", + true + ); + }); + }); + + describe("stale tile handling", () => { + const backedCalls = (el: HTMLVideoElement) => + setTileParticipantBacked.mock.calls + .filter(([v]) => v === el) + .map(([, backed]) => backed); + const lastBacked = (el: HTMLVideoElement) => backedCalls(el).at(-1); + + it("records the matched sessionId on the video element", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [makeParticipant({ sessionId: "s1" })], + }) + ); + expect(a.video.dataset.lastSessionId).toBe("s1"); + }); + + it("backs an untracked tile while some remote publisher is in the call", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match(null)], + participants: [makeParticipant({ sessionId: "s1" })], + }) + ); + expect(lastBacked(a.video)).toBe(true); + }); + + it("backs tiles whose participant is active with video", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [makeParticipant({ sessionId: "s1" })], + }) + ); + expect(lastBacked(a.video)).toBe(true); + expect(updateVideoTileLabel).toHaveBeenCalledWith( + a.video, + "Alice", + false + ); + }); + + it("unbacks every tile once no remote publisher is left, keeping them in the grid", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const { grid } = renderGrid( + baseProps({ + videoElements: [a.video, b.video], + tileMatches: [match("s1", "Alice"), match(null)], + participants: [], + }) + ); + expect(lastBacked(a.video)).toBe(false); + expect(lastBacked(b.video)).toBe(false); + expect(childIds(grid)).toEqual(["a", "b"]); + }); + + it("ignores self and WHEP receivers when deciding if anyone publishes", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match(null)], + selfSessionId: "self", + participants: [ + makeParticipant({ sessionId: "self" }), + makeParticipant({ sessionId: "r", isWhepReceiver: true }), + ], + }) + ); + expect(lastBacked(a.video)).toBe(false); + }); + + it("unbacks a tile whose participant is inactive, even if its track is still live", () => { + const a = makeTile("a"); + a.video.srcObject = new MediaStream([makeTrack("live")]); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [ + makeParticipant({ sessionId: "s1", isActive: false }), + makeParticipant({ sessionId: "s2" }), + ], + }) + ); + expect(lastBacked(a.video)).toBe(false); + }); + + it("unbacks a tile whose participant is active but has stopped sending video", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [ + makeParticipant({ sessionId: "s1", hasVideo: false }), + makeParticipant({ sessionId: "s2" }), + ], + }) + ); + expect(lastBacked(a.video)).toBe(false); + }); + + it("clears the label and lastSessionId of an unbacked tile", () => { + const a = makeTile("a"); + renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [makeParticipant({ sessionId: "s2" })], + }) + ); + expect(updateVideoTileLabel).toHaveBeenCalledWith(a.video, null, false); + expect(updateVideoTileLabel).not.toHaveBeenCalledWith( + a.video, + "Alice", + expect.anything() + ); + expect(a.video.dataset.lastSessionId).toBe(""); + }); + + it("falls back to dataset.lastSessionId once the match disappears", () => { + const a = makeTile("a"); + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [ + makeParticipant({ sessionId: "s1" }), + makeParticipant({ sessionId: "s2" }), + ], + }) + ); + expect(lastBacked(a.video)).toBe(true); + + rerender( + baseProps({ + videoElements: [a.video], + tileMatches: [match(null)], + participants: [ + makeParticipant({ sessionId: "s1", isActive: false }), + makeParticipant({ sessionId: "s2" }), + ], + }) + ); + expect(lastBacked(a.video)).toBe(false); + }); + + it("backs the tile again when a new publisher takes over the slot", () => { + const a = makeTile("a"); + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video], + tileMatches: [match("s1", "Alice")], + participants: [makeParticipant({ sessionId: "s1" })], + }) + ); + + rerender( + baseProps({ + videoElements: [a.video], + tileMatches: [match(null)], + participants: [], + }) + ); + expect(lastBacked(a.video)).toBe(false); + + rerender( + baseProps({ + videoElements: [a.video], + tileMatches: [match(null)], + participants: [makeParticipant({ sessionId: "s2", name: "Bob" })], + }) + ); + expect(lastBacked(a.video)).toBe(true); + }); + + it("keeps the pinned tile first even when an earlier tile is unbacked", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const c = makeTile("c"); + const { grid } = renderGrid( + baseProps({ + videoElements: [a.video, b.video, c.video], + tileMatches: [match("gone"), match(null), match(null)], + participants: [makeParticipant({ sessionId: "s2" })], + pinnedContainer: c.tile, + }) + ); + expect(lastBacked(a.video)).toBe(false); + expect(childIds(grid)).toEqual(["c", "a", "b"]); + }); + }); + + describe("camera PiP", () => { + it("pushes the camera stream and label into every tile", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const stream = new MediaStream(); + renderGrid( + baseProps({ + videoElements: [a.video, b.video], + cameraStream: stream, + cameraLabel: "FaceTime HD", + }) + ); + expect(setTilePipStream).toHaveBeenCalledWith(a.tile, stream); + expect(setTilePipStream).toHaveBeenCalledWith(b.tile, stream); + expect(setTilePipLabel).toHaveBeenCalledWith(a.tile, "FaceTime HD"); + expect(setTilePipLabel).toHaveBeenCalledWith(b.tile, "FaceTime HD"); + }); + + it("clears the PiP stream from every tile on unmount", () => { + const a = makeTile("a"); + const stream = new MediaStream(); + const { unmount } = renderGrid( + baseProps({ videoElements: [a.video], cameraStream: stream }) + ); + setTilePipStream.mockClear(); + + unmount(); + + expect(setTilePipStream).toHaveBeenCalledWith(a.tile, null); + }); + + it("skips video elements without a parent tile", () => { + const video = document.createElement("video"); + renderGrid( + baseProps({ videoElements: [video], cameraStream: new MediaStream() }) + ); + expect(setTilePipStream).not.toHaveBeenCalled(); + expect(updateVideoTileContainerPinned).not.toHaveBeenCalled(); + }); + }); + + describe("pin transition overlay", () => { + it("does not show the overlay on the first pin (no previous pin)", () => { + const a = makeTile("a"); + const { rerender } = renderGrid( + baseProps({ videoElements: [a.video], pinnedSessionId: null }) + ); + rerender(baseProps({ videoElements: [a.video], pinnedSessionId: "s1" })); + expect(setTilePinTransitioning).not.toHaveBeenCalled(); + }); + + it("shows the overlay when switching pins and clears it once the pin settles (no rVFC)", async () => { + const a = makeTile("a"); + const ref = { current: null } as MutableRefObject | null>; + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s1", + pinnedContainer: a.tile, + pendingPinPromiseRef: ref, + }) + ); + + let resolvePin!: () => void; + ref.current = new Promise((resolve) => { + resolvePin = resolve; + }); + + rerender( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s2", + pinnedContainer: a.tile, + pendingPinPromiseRef: ref, + }) + ); + expect(setTilePinTransitioning).toHaveBeenCalledWith(a.tile, true); + expect(setTilePinTransitioning).not.toHaveBeenCalledWith(a.tile, false); + + await act(async () => { + resolvePin(); + await Promise.resolve(); + }); + + expect(setTilePinTransitioning).toHaveBeenLastCalledWith(a.tile, false); + }); + + it("targets every tile when switching pins with no pinned container", () => { + const a = makeTile("a"); + const b = makeTile("b"); + const ref = { + current: new Promise(() => {}), + } as MutableRefObject | null>; + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video, b.video], + pinnedSessionId: "s1", + pendingPinPromiseRef: ref, + }) + ); + rerender( + baseProps({ + videoElements: [a.video, b.video], + pinnedSessionId: "s2", + pendingPinPromiseRef: ref, + }) + ); + expect(setTilePinTransitioning).toHaveBeenCalledWith(a.tile, true); + expect(setTilePinTransitioning).toHaveBeenCalledWith(b.tile, true); + }); + + it("clears the overlay via the 8s safety timer if the pin never settles", () => { + vi.useFakeTimers(); + const a = makeTile("a"); + const ref = { + current: new Promise(() => {}), + } as MutableRefObject | null>; + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s1", + pinnedContainer: a.tile, + pendingPinPromiseRef: ref, + }) + ); + rerender( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s2", + pinnedContainer: a.tile, + pendingPinPromiseRef: ref, + }) + ); + + act(() => { + vi.advanceTimersByTime(7999); + }); + expect(setTilePinTransitioning).not.toHaveBeenCalledWith(a.tile, false); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(setTilePinTransitioning).toHaveBeenCalledWith(a.tile, false); + expect( + setTilePinTransitioning.mock.calls.filter(([, on]) => on === false) + ).toHaveLength(1); + }); + + it("waits for a frame gap via requestVideoFrameCallback before clearing", async () => { + const a = makeTile("a"); + const frameCbs: (( + now: number, + meta: { presentedFrames: number } + ) => void)[] = []; + const rvfc = vi.fn((cb: (typeof frameCbs)[number]) => { + frameCbs.push(cb); + return frameCbs.length; + }); + Object.assign(a.video, { requestVideoFrameCallback: rvfc }); + const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1000); + + const { rerender } = renderGrid( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s1", + pinnedContainer: a.tile, + }) + ); + rerender( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s2", + pinnedContainer: a.tile, + }) + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rvfc).toHaveBeenCalledTimes(1); + + act(() => { + frameCbs[0](1050, { presentedFrames: 1 }); + }); + expect(setTilePinTransitioning).not.toHaveBeenCalledWith(a.tile, false); + expect(rvfc).toHaveBeenCalledTimes(2); + + act(() => { + frameCbs[1](1300, { presentedFrames: 2 }); + }); + expect(setTilePinTransitioning).toHaveBeenCalledWith(a.tile, false); + expect(rvfc).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); + + it("cancels the pending video frame callback and clears the overlay on unmount", async () => { + const a = makeTile("a"); + const rvfc = vi.fn(() => 42); + const cvfc = vi.fn(); + Object.assign(a.video, { + requestVideoFrameCallback: rvfc, + cancelVideoFrameCallback: cvfc, + }); + + const { rerender, unmount } = renderGrid( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s1", + pinnedContainer: a.tile, + }) + ); + rerender( + baseProps({ + videoElements: [a.video], + pinnedSessionId: "s2", + pinnedContainer: a.tile, + }) + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rvfc).toHaveBeenCalledTimes(1); + + unmount(); + + expect(cvfc).toHaveBeenCalledWith(42); + expect(setTilePinTransitioning).toHaveBeenLastCalledWith(a.tile, false); + }); + }); +}); diff --git a/src/components/production-line/use-video-tile-grid.ts b/src/components/production-line/use-video-tile-grid.ts new file mode 100644 index 00000000..b81af957 --- /dev/null +++ b/src/components/production-line/use-video-tile-grid.ts @@ -0,0 +1,251 @@ +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import { LabelResult } from "./match-video-tile-labels.ts"; +import { TParticipant } from "./types.ts"; +import { hasRemoteVideoPublisher } from "./pinnable-participants.ts"; +import { + setTilePinTransitioning, + setTilePipStream, + setTilePipLabel, + setTileParticipantBacked, + updateVideoTileLabel, + updateVideoTileContainerPinned, +} from "./video-element-factory.ts"; + +type UseVideoTileGridArgs = { + videoElements: HTMLVideoElement[] | null; + participants: TParticipant[]; + tileMatches: LabelResult[]; + pinnedContainer: HTMLElement | null; + pinnedSessionId: string | null; + selfSessionId: string | null; + cameraStream: MediaStream | null; + cameraLabel: string | null; + pendingPinPromiseRef: React.MutableRefObject | null>; + onAutoUnpin: () => void; +}; + +type UseVideoTileGridResult = { + setVideoGridEl: Dispatch>; +}; + +export const useVideoTileGrid = ({ + videoElements, + participants, + tileMatches, + pinnedContainer, + pinnedSessionId, + selfSessionId, + cameraStream, + cameraLabel, + pendingPinPromiseRef, + onAutoUnpin, +}: UseVideoTileGridArgs): UseVideoTileGridResult => { + const [videoGridEl, setVideoGridEl] = useState(null); + + useEffect(() => { + const mainGrid = videoGridEl; + if (!mainGrid) return undefined; + + const elements = videoElements || []; + + const anyPublisher = hasRemoteVideoPublisher(participants, selfSessionId); + const unbackedElements = new Set(); + + elements.forEach((el, i) => { + const sessionId = tileMatches[i]?.sessionId ?? null; + if (sessionId) { + // eslint-disable-next-line no-param-reassign + el.dataset.lastSessionId = sessionId; + } + const tracked = sessionId || el.dataset.lastSessionId || null; + const backed = + anyPublisher && + (!tracked || + participants.some( + (p) => p.sessionId === tracked && p.isActive && p.hasVideo + )); + + setTileParticipantBacked(el, backed); + if (!backed) { + unbackedElements.add(el); + // eslint-disable-next-line no-param-reassign + el.dataset.lastSessionId = ""; + } + }); + + const tiles = elements.map((el) => el.parentElement ?? el); + const currentTileSet = new Set(tiles); + + const pinnedParticipant = pinnedSessionId + ? (participants.find((p) => p.sessionId === pinnedSessionId) ?? null) + : null; + elements.forEach((videoEl, idx) => { + if (unbackedElements.has(videoEl)) { + updateVideoTileLabel(videoEl, null, false); + return; + } + const match = tileMatches[idx]; + const name = pinnedParticipant?.name ?? match?.name ?? null; + const isWhip = pinnedParticipant?.isWhip ?? match?.isWhip ?? false; + updateVideoTileLabel(videoEl, name, isWhip); + }); + + Array.from(mainGrid.children).forEach((child) => { + if (!currentTileSet.has(child)) mainGrid.removeChild(child); + }); + + const ordered = + pinnedContainer && tiles.includes(pinnedContainer as HTMLElement) + ? [ + pinnedContainer as HTMLElement, + ...tiles.filter((t) => t !== pinnedContainer), + ] + : tiles; + ordered.forEach((tile, idx) => { + if (mainGrid.children[idx] === tile) return; + mainGrid.insertBefore(tile, mainGrid.children[idx] ?? null); + }); + + return undefined; + }, [ + videoGridEl, + videoElements, + participants, + pinnedContainer, + pinnedSessionId, + selfSessionId, + tileMatches, + ]); + + useEffect(() => { + if (!pinnedContainer) return; + const tiles = (videoElements || []).map((el) => el.parentElement ?? el); + if (!tiles.includes(pinnedContainer)) { + onAutoUnpin(); + } + }, [videoElements, pinnedContainer, onAutoUnpin]); + + useEffect(() => { + (videoElements || []).forEach((el) => { + const tile = el.parentElement as HTMLElement; + if (!tile) return; + const isPinned = tile === pinnedContainer; + tile.style.order = isPinned ? "-1" : "0"; + updateVideoTileContainerPinned(tile, isPinned); + }); + }, [videoElements, pinnedContainer]); + + useEffect(() => { + (videoElements || []).forEach((el) => { + const tile = el.parentElement as HTMLElement | null; + if (!tile) return; + setTilePipStream(tile, cameraStream); + setTilePipLabel(tile, cameraLabel); + }); + return () => { + (videoElements || []).forEach((el) => { + const tile = el.parentElement as HTMLElement | null; + if (!tile) return; + setTilePipStream(tile, null); + }); + }; + }, [videoElements, cameraStream, cameraLabel]); + + const prevPinnedSessionIdRef = useRef(null); + useEffect(() => { + const prev = prevPinnedSessionIdRef.current; + prevPinnedSessionIdRef.current = pinnedSessionId; + + if (!prev || !pinnedSessionId || prev === pinnedSessionId) return undefined; + + const targets: HTMLElement[] = pinnedContainer + ? [pinnedContainer] + : (videoElements ?? []) + .map((el) => el.parentElement as HTMLElement | null) + .filter((el): el is HTMLElement => el !== null); + + if (targets.length === 0) return undefined; + + type RVFCMetadata = { presentedFrames: number }; + type RVFC = (cb: (now: number, metadata: RVFCMetadata) => void) => number; + type CVFC = (h: number) => void; + + let aborted = false; + const perTarget = targets.map((tile) => { + setTilePinTransitioning(tile, true); + + const clearedRef = { value: false }; + const clear = () => { + if (clearedRef.value) return; + clearedRef.value = true; + setTilePinTransitioning(tile, false); + }; + + const frameCbHandleRef: { value: number | null } = { value: null }; + const videoEl = tile.querySelector("video"); + return { + tile, + videoEl, + frameCbHandleRef, + clearedRef, + clear, + }; + }); + + const pinSettled = pendingPinPromiseRef.current ?? Promise.resolve(); + + pinSettled.finally(() => { + if (aborted) return; + perTarget.forEach((entry) => { + const { videoEl, clearedRef, clear } = entry; + const handleRef = entry.frameCbHandleRef; + const rvfc = videoEl + ? (videoEl as unknown as { requestVideoFrameCallback?: RVFC }) + .requestVideoFrameCallback + : undefined; + + if (videoEl && typeof rvfc === "function") { + const GAP_MS = 120; + let lastFrameAt = performance.now(); + let gapObserved = false; + const onFrame = (now: number) => { + if (clearedRef.value || aborted) return; + if (now - lastFrameAt > GAP_MS) gapObserved = true; + lastFrameAt = now; + if (gapObserved) { + clear(); + return; + } + handleRef.value = rvfc.call(videoEl, onFrame); + }; + handleRef.value = rvfc.call(videoEl, onFrame); + } else { + clear(); + } + }); + }); + + const safetyTimer = setTimeout(() => { + perTarget.forEach(({ clear }) => clear()); + }, 8000); + + return () => { + aborted = true; + clearTimeout(safetyTimer); + perTarget.forEach((entry) => { + const { videoEl, clear } = entry; + const handleRef = entry.frameCbHandleRef; + if (handleRef.value != null && videoEl) { + const cvfc = ( + videoEl as unknown as { cancelVideoFrameCallback?: CVFC } + ).cancelVideoFrameCallback; + if (typeof cvfc === "function") cvfc.call(videoEl, handleRef.value); + } + clear(); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pinnedSessionId, pinnedContainer]); + + return { setVideoGridEl }; +}; diff --git a/src/components/production-line/user-action-slots.tsx b/src/components/production-line/user-action-slots.tsx new file mode 100644 index 00000000..c9ca6171 --- /dev/null +++ b/src/components/production-line/user-action-slots.tsx @@ -0,0 +1,229 @@ +import styled from "@emotion/styled"; +import { + LogoutIcon, + MicMuted, + PinIcon, + UnpinIcon, + WhipIcon, +} from "../../assets/icons/icon.tsx"; +import { Tooltip } from "../tooltip/tooltip.tsx"; +import { TParticipant } from "./types.ts"; + +const MuteParticipantButton = styled.button` + width: 3rem; + height: 3rem; + padding: 0.3rem; + margin: 0 0 0 0.5rem; + background: #302b2b; + border: 0.1rem solid #707070; + border-radius: 0.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + fill: #f96c6c; + display: block; + } +`; + +const KickParticipantButton = styled.button` + width: 3rem; + height: 3rem; + padding: 0.3rem; + margin: 0 0 0 0.5rem; + background: #302b2b; + border: 0.1rem solid #707070; + border-radius: 0.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + fill: #f96c6c; + display: block; + width: 2rem; + } +`; + +type TVideoOptionsButtonProps = { isActive: boolean }; + +const VideoOptionsButton = styled.button` + width: 3rem; + height: 3rem; + padding: 0.3rem; + margin: 0 0 0 0.5rem; + background: ${({ isActive }) => + isActive ? "rgba(89, 203, 232, 0.25)" : "#302b2b"}; + border: 0.1rem solid + ${({ isActive }) => (isActive ? "rgb(89, 203, 232)" : "#707070")}; + border-radius: 0.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + && svg { + fill: ${({ isActive }) => (isActive ? "rgb(89, 203, 232)" : "#D9D9D9")}; + display: block; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +`; + +const ActiveIconWrapper = styled.div` + width: 3rem; + height: 3rem; + margin: 0 0 0 0.5rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + && svg { + fill: rgb(89, 203, 232); + display: block; + width: 3rem; + height: 3rem; + } +`; + +const Wrapper = styled.div` + display: flex; + align-items: center; + flex-shrink: 0; +`; + +const Slot = styled.div` + width: 3rem; + height: 3rem; + margin: 0 0 0 0.5rem; + flex-shrink: 0; +`; + +type UserActionSlotsProps = { + participant: TParticipant; + isYou: boolean; + programOutputLine?: boolean; + videoEnabled?: boolean; + pinnedVideoSessionId?: string | null; + whepSourceSessionId?: string | null; + pinnableCount: number; + onPin?: (sessionId: string) => void; + onSetWhep?: (sessionId: string) => void; + onRequestMute: (endpointId: string, name: string) => void; + onRequestKick: (sessionId: string, name: string) => void; +}; + +export const UserActionSlots = ({ + participant: p, + isYou, + programOutputLine, + videoEnabled, + pinnedVideoSessionId, + whepSourceSessionId, + pinnableCount, + onPin, + onSetWhep, + onRequestMute, + onRequestKick, +}: UserActionSlotsProps) => { + const isVideoTargetable = + p.isActive && !programOutputLine && !p.isWhepReceiver && p.hasVideo; + const showWhepControl = !!videoEnabled && isVideoTargetable && !!onSetWhep; + const showPinControl = + !!videoEnabled && !isYou && isVideoTargetable && !!onPin; + const showMuteControl = + !isYou && p.isActive && !programOutputLine && !p.isWhip; + // Kick targets the same audience as mute: another active, non-WHIP + // participant on a non-program line. + const showKickControl = showMuteControl; + + const isPinned = pinnedVideoSessionId === p.sessionId; + const pinDisabled = isPinned && pinnableCount <= 1; + let pinTooltip = "Pin video"; + if (pinDisabled) { + pinTooltip = "Can't unpin — they're the only available video source"; + } else if (isPinned) { + pinTooltip = "Unpin video"; + } + + let whepTooltip = "Set as WHEP source"; + if (whepSourceSessionId === p.sessionId) { + whepTooltip = "Clear WHEP source"; + } else if (p.isWhip) { + whepTooltip = "WHIP session can not be WHEP source"; + } + + const showSelfWhepIndicator = + isYou && p.sessionId === whepSourceSessionId && !showWhepControl; + + return ( + + {showWhepControl ? ( + + onSetWhep?.(p.sessionId)} + > + + + + ) : ( + videoEnabled && + )} + {showPinControl ? ( + + onPin?.(p.sessionId)} + > + {isPinned ? : } + + + ) : ( + videoEnabled && + )} + {/* eslint-disable-next-line no-nested-ternary */} + {showMuteControl ? ( + + onRequestMute(p.endpointId, p.name)} + > + + + + ) : showSelfWhepIndicator ? ( + + + + + + ) : ( + + )} + {showKickControl ? ( + + onRequestKick(p.sessionId, p.name)} + > + + + + ) : ( + + )} + + ); +}; diff --git a/src/components/production-line/user-controls.tsx b/src/components/production-line/user-controls.tsx index 313f30f8..305b8348 100644 --- a/src/components/production-line/user-controls.tsx +++ b/src/components/production-line/user-controls.tsx @@ -4,6 +4,8 @@ import { MicUnmuted, SpeakerOff, SpeakerOn, + VideoOnIcon, + VideoOffIcon, } from "../../assets/icons/icon"; import { isIOSMobile, isIpad } from "../../bowser"; import { VolumeSlider } from "../volume-slider/volume-slider"; @@ -30,9 +32,13 @@ export const UserControls = ({ isInputMuted, inputAudioStream, value, + videoEnabled, + isVideoMuted, + hasCamera, muteOutput, muteInput, handleInputChange, + toggleVideo, }: { line: TLine | null; joinProductionOptions: TJoinProductionOptions; @@ -40,9 +46,13 @@ export const UserControls = ({ isInputMuted: boolean; inputAudioStream: TUseAudioInputValues; value: number; + videoEnabled?: boolean; + isVideoMuted?: boolean; + hasCamera?: boolean; muteOutput: () => void; muteInput: () => void; handleInputChange: (e: React.ChangeEvent) => void; + toggleVideo?: () => void; }) => { return ( <> @@ -77,6 +87,14 @@ export const UserControls = ({ )} + + {videoEnabled && hasCamera && toggleVideo && ( + + + {isVideoMuted ? : } + + + )} ); diff --git a/src/components/production-line/user-list.test.tsx b/src/components/production-line/user-list.test.tsx index e5e3978a..78bf7e29 100644 --- a/src/components/production-line/user-list.test.tsx +++ b/src/components/production-line/user-list.test.tsx @@ -12,6 +12,7 @@ const participant: TParticipant = { endpointId: "endpoint-1", isActive: true, isWhip: false, + hasVideo: false, }; const renderList = (overrides?: { diff --git a/src/components/production-line/user-list.tsx b/src/components/production-line/user-list.tsx index e0306b06..8846caa0 100644 --- a/src/components/production-line/user-list.tsx +++ b/src/components/production-line/user-list.tsx @@ -1,21 +1,16 @@ import styled from "@emotion/styled"; -import { - LogoutIcon, - MicMuted, - UserIcon, - WhipIcon, -} from "../../assets/icons/icon.tsx"; +import { UserIcon, WhipIcon } from "../../assets/icons/icon.tsx"; import { TParticipant } from "./types.ts"; import { getStatusClass, ListWrapper, OnlineIndicator, Participant as User, - ParticipantControls, ParticipantName as UserName, ParticipantRow as UserWrapper, truncateParticipantName, } from "./participant-list-primitives.ts"; +import { UserActionSlots } from "./user-action-slots.tsx"; const Container = styled.div` width: 100%; @@ -44,56 +39,20 @@ const IsTalkingIndicator = styled.div` : ""} `; -const MuteParticipantButton = styled.button` - width: 3rem; - height: 3rem; - padding: 0.3rem; - margin: 0 0 0 0.5rem; - background: #302b2b; - border: 0.1rem solid #707070; - border-radius: 0.4rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - fill: #f96c6c; - display: block; - } -`; - -const KickParticipantButton = styled.button` - width: 3rem; - height: 3rem; - padding: 0.3rem; - margin: 0 0 0 0.5rem; - background: #302b2b; - border: 0.1rem solid #707070; - border-radius: 0.4rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - fill: #f96c6c; - display: block; - width: 2rem; - } -`; - type TUserListOptions = { participants: TParticipant[]; sessionId: string | null; dominantSpeaker: string | null; audioLevelAboveThreshold: boolean; programOutputLine?: boolean; + videoEnabled?: boolean; + pinnedVideoSessionId?: string | null; + whepSourceSessionId?: string | null; setConfirmModalOpen: (value: boolean) => void; setUserId: (value: string) => void; setUserName: (value: string) => void; + onPin?: (sessionId: string) => void; + onSetWhep?: (sessionId: string) => void; setKickModalOpen: (value: boolean) => void; setKickSessionId: (value: string) => void; setKickUserName: (value: string) => void; @@ -105,9 +64,14 @@ export const UserList = ({ dominantSpeaker, audioLevelAboveThreshold, programOutputLine, + videoEnabled, + pinnedVideoSessionId, + whepSourceSessionId, setConfirmModalOpen, setUserId, setUserName, + onPin, + onSetWhep, setKickModalOpen, setKickSessionId, setKickUserName, @@ -116,6 +80,11 @@ export const UserList = ({ const isWhipOnLine = participants.some((p) => p.isWhip); + const pinnableCount = participants.filter( + (p) => + p.sessionId !== sessionId && p.isActive && p.hasVideo && !p.isWhepReceiver + ).length; + return ( @@ -142,32 +111,27 @@ export const UserList = ({ {truncatedUsername} {p.isActive ? "" : "(inactive)"} - {!isYou && p.isActive && !programOutputLine && !p.isWhip && ( - - { - setUserId(p.endpointId); - setUserName(p.name); - setConfirmModalOpen(true); - }} - > - - - { - setKickSessionId(p.sessionId); - setKickUserName(p.name); - setKickModalOpen(true); - }} - > - - - - )} + { + setUserId(endpointId); + setUserName(name); + setConfirmModalOpen(true); + }} + onRequestKick={(kickSessionId, name) => { + setKickSessionId(kickSessionId); + setKickUserName(name); + setKickModalOpen(true); + }} + /> ); })} diff --git a/src/components/production-line/video-element-factory.test.ts b/src/components/production-line/video-element-factory.test.ts new file mode 100644 index 00000000..9abc4808 --- /dev/null +++ b/src/components/production-line/video-element-factory.test.ts @@ -0,0 +1,772 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + attachShowWhenReady, + createVideoElement, + createVideoTileContainer, + exitTileFullscreen, + isTileFullscreen, + makePipDraggable, + requestTileFullscreen, + setTileParticipantBacked, + setTilePinTransitioning, + setTilePipLabel, + setTilePipStream, + stopFrameMonitor, + updateVideoTileContainerPinned, + updateVideoTileLabel, + setVideoTileVisible, +} from "./video-element-factory.ts"; + +const OPTIONS_BTN_BG_DEFAULT = "rgba(0, 0, 0, 0.45)"; +const OPTIONS_BTN_BG_PINNED = "rgba(89, 203, 232, 0.80)"; + +type FakeTrack = { muted: boolean; readyState: "live" | "ended" }; +const createStream = (tracks: FakeTrack[] = []): MediaStream => { + const stream = new MediaStream(); + vi.spyOn(stream, "getVideoTracks").mockReturnValue( + tracks as unknown as MediaStreamTrack[] + ); + return stream; +}; + +const setVideoSize = (video: HTMLVideoElement, w: number, h: number) => { + Object.defineProperty(video, "videoWidth", { value: w, configurable: true }); + Object.defineProperty(video, "videoHeight", { value: h, configurable: true }); +}; + +const getTileParts = (container: HTMLElement) => ({ + label: container.querySelector("[data-video-tile-label]"), + optionsBtn: container.querySelector( + "[data-tile-options-btn]" + ), + overlay: container.querySelector( + "[data-pin-transition-overlay]" + ), + pip: container.querySelector("[data-camera-pip]"), + pipVideo: container.querySelector( + "[data-camera-pip] video" + ), + pipLabel: container.querySelector( + "[data-camera-pip] [data-camera-pip-label]" + ), +}); + +describe("video-element-factory", () => { + let playSpy: ReturnType; + + beforeEach(() => { + playSpy = vi + .spyOn(HTMLMediaElement.prototype, "play") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + describe("createVideoElement", () => { + it("creates a muted, inline, autoplaying video element", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + + expect(el).toBeInstanceOf(HTMLVideoElement); + expect(el.autoplay).toBe(true); + expect(el.playsInline).toBe(true); + expect(el.muted).toBe(true); + stopFrameMonitor(el); + }); + + it("sets srcObject to the provided stream", () => { + const stream = createStream(); + const el = createVideoElement({ stream, lineId: "line-1" }); + + expect(el.srcObject).toBe(stream); + stopFrameMonitor(el); + }); + + it("sets an id containing the lineId", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "my-line", + }); + + expect(el.id).toContain("rtc-video-my-line-"); + stopFrameMonitor(el); + }); + + it("stores the endpointId in the dataset", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + endpointId: "endpoint-42", + }); + + expect(el.dataset.endpointId).toBe("endpoint-42"); + stopFrameMonitor(el); + }); + + it("defaults dataset.endpointId to an empty string when omitted or null", () => { + const omitted = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + const nulled = createVideoElement({ + stream: createStream(), + lineId: "line-1", + endpointId: null, + }); + + expect(omitted.dataset.endpointId).toBe(""); + expect(nulled.dataset.endpointId).toBe(""); + stopFrameMonitor(omitted); + stopFrameMonitor(nulled); + }); + + it("calls play() on the created element", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + + expect(playSpy).toHaveBeenCalledTimes(1); + expect(playSpy.mock.contexts[0]).toBe(el); + stopFrameMonitor(el); + }); + + it("swallows a rejected play() (e.g. autoplay blocked)", async () => { + playSpy.mockRejectedValue(new Error("NotAllowedError")); + + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(el).toBeInstanceOf(HTMLVideoElement); + stopFrameMonitor(el); + }); + + it("mounts the video inside a hidden tile container", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + const container = el.parentElement as HTMLDivElement; + + expect(container).toBeInstanceOf(HTMLDivElement); + expect(container.style.display).toBe("none"); + expect(container.lastElementChild).toBe(el); + const parts = getTileParts(container); + expect(parts.label).not.toBeNull(); + expect(parts.optionsBtn).not.toBeNull(); + expect(parts.overlay).not.toBeNull(); + expect(parts.pip).not.toBeNull(); + stopFrameMonitor(el); + }); + + it("reveals the tile once the video reports dimensions (no-rVFC fallback)", () => { + const el = createVideoElement({ + stream: createStream(), + lineId: "line-1", + }); + const container = el.parentElement as HTMLDivElement; + + el.dispatchEvent(new Event("loadedmetadata")); + expect(container.style.display).toBe("none"); + + setVideoSize(el, 1280, 720); + el.dispatchEvent(new Event("loadedmetadata")); + expect(container.style.display).toBe("block"); + stopFrameMonitor(el); + }); + }); + + describe("createVideoTileContainer", () => { + it("returns a 16:9 relative container and its label", () => { + const { container, label } = createVideoTileContainer(); + + expect(container.style.position).toBe("relative"); + expect(container.style.aspectRatio).toBe("16 / 9"); + expect(label.dataset.videoTileLabel).toBe("true"); + expect(label.style.display).toBe("none"); + expect(container.contains(label)).toBe(true); + }); + + it("includes a fullscreen options button marked with data-tile-options-btn", () => { + const { container } = createVideoTileContainer(); + const btn = getTileParts(container).optionsBtn; + + expect(btn).toBeInstanceOf(HTMLButtonElement); + expect(btn?.dataset.tileOptionsBtn).toBe("true"); + expect(btn?.type).toBe("button"); + expect(btn?.title).toBe("Fullscreen"); + expect(btn?.style.background).toBe(OPTIONS_BTN_BG_DEFAULT); + const svg = btn?.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute("width")).toBe("1.8rem"); + expect(svg?.getAttribute("height")).toBe("1.8rem"); + expect(svg?.getAttribute("fill")).toBe("currentColor"); + }); + + it("includes a hidden pin-transition overlay", () => { + const { container } = createVideoTileContainer(); + const { overlay } = getTileParts(container); + + expect(overlay?.dataset.pinTransitionOverlay).toBe("true"); + expect(overlay?.style.display).toBe("none"); + }); + + it("includes a hidden, draggable camera PiP with a muted video and CAM label", () => { + const { container } = createVideoTileContainer(); + const { pip, pipVideo, pipLabel } = getTileParts(container); + + expect(pip?.dataset.cameraPip).toBe("true"); + expect(pip?.style.display).toBe("none"); + expect(pip?.style.cursor).toBe("grab"); + expect(pip?.style.touchAction).toBe("none"); + expect(pipVideo?.muted).toBe(true); + expect(pipVideo?.autoplay).toBe(true); + expect(pipVideo?.playsInline).toBe(true); + expect(pipLabel?.textContent).toBe("CAM"); + }); + + it("injects the spinner keyframes stylesheet only once", () => { + createVideoTileContainer(); + createVideoTileContainer(); + + expect( + document.head.querySelectorAll("#tile-pin-spin-keyframes") + ).toHaveLength(1); + }); + + it("restores the pinned resting style on mouseleave", () => { + const { container } = createVideoTileContainer(); + const btn = getTileParts(container).optionsBtn as HTMLButtonElement; + + btn.dispatchEvent(new Event("mouseenter")); + expect(btn.style.opacity).toBe("1"); + + btn.dispatchEvent(new Event("mouseleave")); + expect(btn.style.background).toBe(OPTIONS_BTN_BG_DEFAULT); + expect(btn.style.opacity).toBe("0.7"); + + updateVideoTileContainerPinned(container, true); + btn.dispatchEvent(new Event("mouseenter")); + btn.dispatchEvent(new Event("mouseleave")); + expect(btn.style.background).toBe(OPTIONS_BTN_BG_PINNED); + expect(btn.style.opacity).toBe("1"); + }); + }); + + describe("tile fullscreen button", () => { + afterEach(() => { + Reflect.deleteProperty(document, "fullscreenElement"); + Reflect.deleteProperty(document, "exitFullscreen"); + }); + + const setFullscreenElement = (el: Element | null) => { + Object.defineProperty(document, "fullscreenElement", { + value: el, + configurable: true, + }); + }; + + it("requests fullscreen on the container when clicked", () => { + vi.useFakeTimers(); + const { container } = createVideoTileContainer(); + const requestFullscreen = vi.fn().mockResolvedValue(undefined); + container.requestFullscreen = requestFullscreen; + + getTileParts(container).optionsBtn?.click(); + + expect(requestFullscreen).toHaveBeenCalledTimes(1); + }); + + it("swaps to exit state on fullscreenchange and back when leaving", () => { + vi.useFakeTimers(); + const { container } = createVideoTileContainer(); + container.requestFullscreen = vi.fn().mockResolvedValue(undefined); + setTilePipStream(container, createStream()); + const { optionsBtn, pip } = getTileParts(container); + + optionsBtn?.click(); + setFullscreenElement(container); + document.dispatchEvent(new Event("fullscreenchange")); + + expect(optionsBtn?.title).toBe("Exit fullscreen"); + expect(container.style.height).toBe("100%"); + expect(container.style.aspectRatio).toBe(""); + expect(pip?.style.display).toBe("block"); + + setFullscreenElement(null); + document.dispatchEvent(new Event("fullscreenchange")); + + expect(optionsBtn?.title).toBe("Fullscreen"); + expect(container.style.aspectRatio).toBe("16 / 9"); + expect(pip?.style.display).toBe("none"); + }); + + it("keeps the PiP hidden in fullscreen when no camera stream is set", () => { + vi.useFakeTimers(); + const { container } = createVideoTileContainer(); + container.requestFullscreen = vi.fn().mockResolvedValue(undefined); + const { optionsBtn, pip } = getTileParts(container); + + optionsBtn?.click(); + setFullscreenElement(container); + document.dispatchEvent(new Event("fullscreenchange")); + + expect(pip?.style.display).toBe("none"); + }); + + it("exits fullscreen when clicked while the tile is fullscreen", () => { + const { container } = createVideoTileContainer(); + const requestFullscreen = vi.fn().mockResolvedValue(undefined); + container.requestFullscreen = requestFullscreen; + const exitFullscreen = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(document, "exitFullscreen", { + value: exitFullscreen, + configurable: true, + }); + setFullscreenElement(container); + + getTileParts(container).optionsBtn?.click(); + + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect(requestFullscreen).not.toHaveBeenCalled(); + }); + }); + + describe("fullscreen helpers", () => { + afterEach(() => { + Reflect.deleteProperty(document, "fullscreenElement"); + Reflect.deleteProperty(document, "webkitFullscreenElement"); + Reflect.deleteProperty(document, "exitFullscreen"); + Reflect.deleteProperty(document, "webkitExitFullscreen"); + }); + + it("requestTileFullscreen falls back to webkitEnterFullscreen when requestFullscreen rejects", async () => { + const container = document.createElement("div"); + container.requestFullscreen = vi + .fn() + .mockRejectedValue(new Error("not allowed")); + const video = document.createElement("video"); + const webkitEnterFullscreen = vi.fn(); + Object.assign(video, { webkitEnterFullscreen }); + + requestTileFullscreen(container, video); + + await vi.waitFor(() => { + expect(webkitEnterFullscreen).toHaveBeenCalledTimes(1); + }); + }); + + it("requestTileFullscreen uses webkitEnterFullscreen when requestFullscreen is missing (iOS)", () => { + const container = document.createElement("div"); + Object.defineProperty(container, "requestFullscreen", { + value: undefined, + configurable: true, + }); + const video = document.createElement("video"); + const webkitEnterFullscreen = vi.fn(); + Object.assign(video, { webkitEnterFullscreen }); + + requestTileFullscreen(container, video); + + expect(webkitEnterFullscreen).toHaveBeenCalledTimes(1); + }); + + it("isTileFullscreen honours both the standard and webkit fullscreen element", () => { + const container = document.createElement("div"); + expect(isTileFullscreen(container)).toBe(false); + + Object.defineProperty(document, "webkitFullscreenElement", { + value: container, + configurable: true, + }); + expect(isTileFullscreen(container)).toBe(true); + + Reflect.deleteProperty(document, "webkitFullscreenElement"); + Object.defineProperty(document, "fullscreenElement", { + value: container, + configurable: true, + }); + expect(isTileFullscreen(container)).toBe(true); + }); + + it("exitTileFullscreen prefers exitFullscreen and falls back to webkitExitFullscreen", () => { + const exitFullscreen = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(document, "exitFullscreen", { + value: exitFullscreen, + configurable: true, + }); + exitTileFullscreen(); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + + const webkitExitFullscreen = vi.fn(); + Object.defineProperty(document, "exitFullscreen", { + value: undefined, + configurable: true, + }); + Object.defineProperty(document, "webkitExitFullscreen", { + value: webkitExitFullscreen, + configurable: true, + }); + exitTileFullscreen(); + expect(webkitExitFullscreen).toHaveBeenCalledTimes(1); + }); + }); + + describe("updateVideoTileContainerPinned", () => { + it("sets dataset.pinned to 'true' and applies the pinned button style", () => { + const { container } = createVideoTileContainer(); + const btn = getTileParts(container).optionsBtn as HTMLButtonElement; + + updateVideoTileContainerPinned(container, true); + + expect(container.dataset.pinned).toBe("true"); + expect(btn.style.background).toBe(OPTIONS_BTN_BG_PINNED); + expect(btn.style.opacity).toBe("1"); + }); + + it("clears dataset.pinned and restores the default button style when unpinned", () => { + const { container } = createVideoTileContainer(); + const btn = getTileParts(container).optionsBtn as HTMLButtonElement; + + updateVideoTileContainerPinned(container, true); + updateVideoTileContainerPinned(container, false); + + expect(container.dataset.pinned).toBe(""); + expect(container.dataset.pinned === "true").toBe(false); + expect(btn.style.background).toBe(OPTIONS_BTN_BG_DEFAULT); + expect(btn.style.opacity).toBe("0.7"); + }); + + it("is a no-op for a container without an options button", () => { + const container = document.createElement("div"); + + updateVideoTileContainerPinned(container, true); + + expect(container.dataset.pinned).toBeUndefined(); + }); + }); + + describe("tile label / PiP / overlay helpers", () => { + const mountVideo = () => { + const { container, label } = createVideoTileContainer(); + const video = document.createElement("video"); + container.appendChild(video); + return { container, label, video }; + }; + + it("updateVideoTileLabel shows the name with the user colour", () => { + const { label, video } = mountVideo(); + + updateVideoTileLabel(video, "Alice", false); + + expect(label.textContent).toBe("Alice"); + expect(label.style.display).toBe("inline-block"); + expect(label.style.backgroundColor).toBe("rgba(123, 226, 123, 0.82)"); + }); + + it("updateVideoTileLabel uses the WHIP colour for WHIP sources", () => { + const { label, video } = mountVideo(); + + updateVideoTileLabel(video, "Studio cam", true); + + expect(label.style.backgroundColor).toBe("rgba(89, 203, 232, 0.82)"); + }); + + it("updateVideoTileLabel hides and clears the label when name is null", () => { + const { label, video } = mountVideo(); + updateVideoTileLabel(video, "Alice", false); + + updateVideoTileLabel(video, null, false); + + expect(label.textContent).toBe(""); + expect(label.style.display).toBe("none"); + }); + + it("updateVideoTileLabel does nothing for a detached video", () => { + const video = document.createElement("video"); + + expect(() => updateVideoTileLabel(video, "Alice", false)).not.toThrow(); + }); + + it("setTilePipStream assigns the stream and plays it once", () => { + const { container } = createVideoTileContainer(); + const { pipVideo } = getTileParts(container); + const stream = createStream(); + + setTilePipStream(container, stream); + setTilePipStream(container, stream); + + expect(pipVideo?.srcObject).toBe(stream); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("setTilePipStream clears the stream without calling play()", () => { + const { container } = createVideoTileContainer(); + const { pipVideo } = getTileParts(container); + setTilePipStream(container, createStream()); + playSpy.mockClear(); + + setTilePipStream(container, null); + + expect(pipVideo?.srcObject).toBeNull(); + expect(playSpy).not.toHaveBeenCalled(); + }); + + it("setTilePipLabel sets the label and falls back to CAM for blank names", () => { + const { container } = createVideoTileContainer(); + const { pipLabel } = getTileParts(container); + + setTilePipLabel(container, "Bob"); + expect(pipLabel?.textContent).toBe("Bob"); + + setTilePipLabel(container, " "); + expect(pipLabel?.textContent).toBe("CAM"); + + setTilePipLabel(container, null); + expect(pipLabel?.textContent).toBe("CAM"); + }); + + it("setTilePinTransitioning toggles the overlay", () => { + const { container } = createVideoTileContainer(); + const { overlay } = getTileParts(container); + + setTilePinTransitioning(container, true); + expect(overlay?.style.display).toBe("flex"); + + setTilePinTransitioning(container, false); + expect(overlay?.style.display).toBe("none"); + }); + }); + + describe("makePipDraggable", () => { + const setup = () => { + const parent = document.createElement("div"); + const pip = document.createElement("div"); + parent.appendChild(pip); + document.body.appendChild(parent); + vi.spyOn(parent, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + width: 400, + height: 300, + } as DOMRect); + vi.spyOn(pip, "getBoundingClientRect").mockReturnValue({ + left: 20, + top: 200, + width: 100, + height: 50, + } as DOMRect); + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + Object.assign(pip, { setPointerCapture, releasePointerCapture }); + const cleanup = makePipDraggable(pip, parent); + return { pip, cleanup, setPointerCapture, releasePointerCapture }; + }; + + const pointer = ( + type: string, + clientX: number, + clientY: number, + button = 0 + ) => + new PointerEvent(type, { + clientX, + clientY, + button, + pointerId: 1, + pointerType: "mouse", + bubbles: true, + cancelable: true, + }); + + it("moves the PiP with the pointer and clamps it inside the parent", () => { + const { pip, setPointerCapture, releasePointerCapture } = setup(); + + pip.dispatchEvent(pointer("pointerdown", 50, 220)); + expect(setPointerCapture).toHaveBeenCalledWith(1); + expect(pip.style.left).toBe("20px"); + expect(pip.style.top).toBe("200px"); + expect(pip.style.cursor).toBe("grabbing"); + + pip.dispatchEvent(pointer("pointermove", 80, 200)); + expect(pip.style.left).toBe("50px"); + expect(pip.style.top).toBe("180px"); + + pip.dispatchEvent(pointer("pointermove", 5000, 5000)); + expect(pip.style.left).toBe("300px"); + expect(pip.style.top).toBe("250px"); + + pip.dispatchEvent(pointer("pointerup", 5000, 5000)); + expect(releasePointerCapture).toHaveBeenCalledWith(1); + expect(pip.style.cursor).toBe("grab"); + + pip.dispatchEvent(pointer("pointermove", 0, 0)); + expect(pip.style.left).toBe("300px"); + }); + + it("ignores non-primary mouse buttons", () => { + const { pip, setPointerCapture } = setup(); + + pip.dispatchEvent(pointer("pointerdown", 50, 220, 2)); + + expect(setPointerCapture).not.toHaveBeenCalled(); + expect(pip.style.cursor).toBe("grab"); + }); + + it("stops reacting to pointer events after cleanup", () => { + const { pip, cleanup, setPointerCapture } = setup(); + + cleanup(); + pip.dispatchEvent(pointer("pointerdown", 50, 220)); + + expect(setPointerCapture).not.toHaveBeenCalled(); + }); + }); + + describe("attachShowWhenReady / stopFrameMonitor", () => { + const setup = (tracks: FakeTrack[] = []) => { + vi.useFakeTimers(); + const container = document.createElement("div"); + container.style.display = "none"; + const video = document.createElement("video"); + video.srcObject = createStream(tracks); + container.appendChild(video); + return { container, video }; + }; + + it("starts a single poll interval", () => { + const { container, video } = setup(); + + attachShowWhenReady(video, container); + + expect(vi.getTimerCount()).toBe(1); + stopFrameMonitor(video); + }); + + it("shows immediately when dimensions are already known", () => { + const { container, video } = setup([ + { muted: false, readyState: "live" }, + ]); + setVideoSize(video, 640, 360); + + attachShowWhenReady(video, container); + + expect(container.style.display).toBe("block"); + stopFrameMonitor(video); + }); + + it("shows on media events once dimensions appear", () => { + const { container, video } = setup(); + attachShowWhenReady(video, container); + + video.dispatchEvent(new Event("playing")); + expect(container.style.display).toBe("none"); + + setVideoSize(video, 640, 360); + video.dispatchEvent(new Event("resize")); + expect(container.style.display).toBe("block"); + stopFrameMonitor(video); + }); + + it("hides the tile on the next poll once every track is muted or ended", () => { + const track: FakeTrack = { muted: false, readyState: "live" }; + const { container, video } = setup([track]); + setVideoSize(video, 640, 360); + attachShowWhenReady(video, container); + expect(container.style.display).toBe("block"); + + track.muted = true; + vi.advanceTimersByTime(500); + expect(container.style.display).toBe("none"); + + track.muted = false; + vi.advanceTimersByTime(500); + expect(container.style.display).toBe("block"); + stopFrameMonitor(video); + }); + + it("keeps the tile hidden while it is not participant-backed, even on media events", () => { + const { container, video } = setup([ + { muted: false, readyState: "live" }, + ]); + setVideoSize(video, 640, 360); + attachShowWhenReady(video, container); + expect(container.style.display).toBe("block"); + + setTileParticipantBacked(video, false); + expect(container.style.display).toBe("none"); + + ["loadedmetadata", "resize", "playing", "canplay"].forEach((type) => + video.dispatchEvent(new Event(type)) + ); + vi.advanceTimersByTime(5000); + expect(container.style.display).toBe("none"); + + setTileParticipantBacked(video, true); + expect(container.style.display).toBe("block"); + stopFrameMonitor(video); + }); + + it("stopFrameMonitor clears the interval and the media event listeners", () => { + const { container, video } = setup([ + { muted: false, readyState: "live" }, + ]); + attachShowWhenReady(video, container); + + stopFrameMonitor(video); + expect(vi.getTimerCount()).toBe(0); + + setVideoSize(video, 640, 360); + ["loadedmetadata", "resize", "playing", "canplay"].forEach((type) => + video.dispatchEvent(new Event(type)) + ); + vi.advanceTimersByTime(5000); + expect(container.style.display).toBe("none"); + }); + + it("re-attaching tears down the previous monitor first", () => { + const { container, video } = setup(); + + attachShowWhenReady(video, container); + attachShowWhenReady(video, container); + + expect(vi.getTimerCount()).toBe(1); + stopFrameMonitor(video); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stopFrameMonitor is a no-op for an unmonitored element", () => { + const video = document.createElement("video"); + + expect(() => stopFrameMonitor(video)).not.toThrow(); + }); + }); + + describe("setVideoTileVisible / setTileParticipantBacked", () => { + it("setVideoTileVisible toggles the container display", () => { + const container = document.createElement("div"); + + setVideoTileVisible(container, true); + expect(container.style.display).toBe("block"); + + setVideoTileVisible(container, false); + expect(container.style.display).toBe("none"); + }); + + it("setTileParticipantBacked is a no-op for an element without a tile", () => { + const video = document.createElement("video"); + + expect(() => setTileParticipantBacked(video, false)).not.toThrow(); + }); + }); +}); diff --git a/src/components/production-line/video-element-factory.ts b/src/components/production-line/video-element-factory.ts new file mode 100644 index 00000000..a58884bd --- /dev/null +++ b/src/components/production-line/video-element-factory.ts @@ -0,0 +1,524 @@ +/* eslint-disable no-param-reassign, @typescript-eslint/no-use-before-define */ +import fullscreenIcon from "../../assets/icons/fullscreen.svg?raw"; +import fullscreenExitIcon from "../../assets/icons/fullscreen_exit.svg?raw"; + +type VideoWithIOSFullscreen = HTMLVideoElement & { + webkitEnterFullscreen?: () => void; +}; +type DocumentWithIOSFullscreen = Document & { + webkitFullscreenElement?: Element | null; + webkitExitFullscreen?: () => Promise | void; +}; + +const enterIOSVideoFullscreen = (videoEl: HTMLVideoElement): boolean => { + const v = videoEl as VideoWithIOSFullscreen; + if (typeof v.webkitEnterFullscreen === "function") { + try { + v.webkitEnterFullscreen(); + return true; + } catch { + return false; + } + } + return false; +}; + +export const requestTileFullscreen = ( + container: HTMLElement, + videoEl: HTMLVideoElement | null +): void => { + if (typeof container.requestFullscreen === "function") { + container.requestFullscreen().catch(() => { + if (videoEl) enterIOSVideoFullscreen(videoEl); + }); + return; + } + if (videoEl) enterIOSVideoFullscreen(videoEl); +}; + +export const exitTileFullscreen = (): void => { + const doc = document as DocumentWithIOSFullscreen; + if (typeof document.exitFullscreen === "function") { + document.exitFullscreen().catch(() => {}); + } else if (typeof doc.webkitExitFullscreen === "function") { + doc.webkitExitFullscreen(); + } +}; + +export const isTileFullscreen = (container: HTMLElement): boolean => { + const doc = document as DocumentWithIOSFullscreen; + return ( + document.fullscreenElement === container || + doc.webkitFullscreenElement === container + ); +}; + +export type VideoElementOptions = { + stream: MediaStream; + lineId: string; + endpointId?: string | null; +}; + +const USER_COLOR = "rgba(123, 226, 123, 0.82)"; +const WHIP_COLOR = "rgba(89, 203, 232, 0.82)"; + +const OPTIONS_BTN_BG_DEFAULT = "rgba(0, 0, 0, 0.45)"; +const OPTIONS_BTN_BG_PINNED = "rgba(89, 203, 232, 0.80)"; + +const ICON_SIZE = "1.8rem"; + +const setButtonIcon = (btn: HTMLButtonElement, icon: string) => { + btn.innerHTML = icon; + const svg = btn.firstElementChild; + if (!svg) return; + svg.setAttribute("width", ICON_SIZE); + svg.setAttribute("height", ICON_SIZE); + svg.setAttribute("fill", "currentColor"); +}; + +const applyLabelStyle = (label: HTMLSpanElement) => { + label.style.position = "absolute"; + label.style.bottom = "0.25rem"; + label.style.right = "0.25rem"; + label.style.padding = "0.25rem 0.75rem"; + label.style.borderRadius = "0.5rem 0.25rem 0.25rem 0.25rem"; + label.style.fontSize = "1.4rem"; + label.style.fontWeight = "600"; + label.style.color = "#1a1a1a"; + label.style.maxWidth = "calc(100% - 0.5rem)"; + label.style.overflow = "hidden"; + label.style.textOverflow = "ellipsis"; + label.style.whiteSpace = "nowrap"; + label.style.pointerEvents = "none"; + label.style.display = "none"; +}; + +export const createVideoTileContainer = (): { + container: HTMLDivElement; + label: HTMLSpanElement; +} => { + const container = document.createElement("div"); + container.style.position = "relative"; + container.style.width = "100%"; + container.style.aspectRatio = "16 / 9"; + container.style.borderRadius = "0.5rem"; + container.style.overflow = "hidden"; + container.style.backgroundColor = "#000"; + + const label = document.createElement("span"); + label.dataset.videoTileLabel = "true"; + applyLabelStyle(label); + container.appendChild(label); + + const fsBtn = document.createElement("button"); + fsBtn.type = "button"; + fsBtn.style.position = "absolute"; + fsBtn.style.top = "0.25rem"; + fsBtn.style.right = "0.25rem"; + fsBtn.style.width = "2.2rem"; + fsBtn.style.height = "2.2rem"; + fsBtn.style.display = "flex"; + fsBtn.style.alignItems = "center"; + fsBtn.style.justifyContent = "center"; + fsBtn.style.background = OPTIONS_BTN_BG_DEFAULT; + fsBtn.style.border = "none"; + fsBtn.style.borderRadius = "0.25rem 0.25rem 0.25rem 0.5rem"; + fsBtn.style.cursor = "pointer"; + fsBtn.style.padding = "0"; + fsBtn.style.color = "#D9D9D9"; + fsBtn.style.opacity = "0.7"; + fsBtn.style.transition = "opacity 0.15s, background 0.15s"; + fsBtn.title = "Fullscreen"; + fsBtn.dataset.tileOptionsBtn = "true"; + setButtonIcon(fsBtn, fullscreenIcon); + + const restingBtnBg = () => + container.dataset.pinned === "true" + ? OPTIONS_BTN_BG_PINNED + : OPTIONS_BTN_BG_DEFAULT; + + fsBtn.addEventListener("mouseenter", () => { + fsBtn.style.opacity = "1"; + fsBtn.style.background = "rgba(0, 0, 0, 0.65)"; + }); + fsBtn.addEventListener("mouseleave", () => { + fsBtn.style.opacity = + restingBtnBg() === OPTIONS_BTN_BG_PINNED ? "1" : "0.7"; + fsBtn.style.background = restingBtnBg(); + }); + + const handleFsChange = () => { + const isFs = document.fullscreenElement === container; + setButtonIcon(fsBtn, isFs ? fullscreenExitIcon : fullscreenIcon); + fsBtn.title = isFs ? "Exit fullscreen" : "Fullscreen"; + const pip = container.querySelector("[data-camera-pip]"); + if (pip) { + const pipVideo = pip.querySelector("video"); + const hasStream = !!pipVideo?.srcObject; + pip.style.display = isFs && hasStream ? "block" : "none"; + } + if (isFs) { + container.style.aspectRatio = ""; + container.style.height = "100%"; + container.style.borderRadius = "0"; + } else { + container.style.aspectRatio = "16 / 9"; + container.style.height = ""; + container.style.borderRadius = "0.5rem"; + document.removeEventListener("fullscreenchange", handleFsChange); + } + }; + + fsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (isTileFullscreen(container)) { + exitTileFullscreen(); + } else { + document.addEventListener("fullscreenchange", handleFsChange); + const videoEl = container.querySelector("video"); + requestTileFullscreen(container, videoEl); + setTimeout(() => { + if (!isTileFullscreen(container)) { + document.removeEventListener("fullscreenchange", handleFsChange); + } + }, 500); + } + }); + + container.appendChild(fsBtn); + + const overlay = document.createElement("div"); + overlay.dataset.pinTransitionOverlay = "true"; + overlay.style.position = "absolute"; + overlay.style.inset = "0"; + overlay.style.display = "none"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.backgroundColor = "#000"; + overlay.style.zIndex = "2"; + + const spinner = document.createElement("div"); + spinner.style.width = "3.6rem"; + spinner.style.height = "3.6rem"; + spinner.style.border = "0.35rem solid rgba(255,255,255,0.18)"; + spinner.style.borderTopColor = "rgba(89, 203, 232, 0.95)"; + spinner.style.borderRadius = "50%"; + spinner.style.animation = "tile-pin-spin 0.8s linear infinite"; + overlay.appendChild(spinner); + + if (!document.getElementById("tile-pin-spin-keyframes")) { + const style = document.createElement("style"); + style.id = "tile-pin-spin-keyframes"; + style.textContent = + "@keyframes tile-pin-spin { to { transform: rotate(360deg); } }"; + document.head.appendChild(style); + } + + container.appendChild(overlay); + + const pip = document.createElement("div"); + pip.dataset.cameraPip = "true"; + pip.style.display = "none"; + pip.style.position = "absolute"; + pip.style.bottom = "1rem"; + pip.style.left = "1rem"; + pip.style.width = "22rem"; + pip.style.height = "12.375rem"; + pip.style.borderRadius = "0.5rem"; + pip.style.overflow = "hidden"; + pip.style.border = "2px solid rgba(123, 226, 123, 0.55)"; + pip.style.boxShadow = "0 2px 16px rgba(0,0,0,0.7)"; + pip.style.zIndex = "11"; + + const pipVideo = document.createElement("video"); + pipVideo.autoplay = true; + pipVideo.playsInline = true; + pipVideo.muted = true; + pipVideo.style.width = "100%"; + pipVideo.style.height = "100%"; + pipVideo.style.objectFit = "cover"; + pipVideo.style.display = "block"; + pipVideo.style.backgroundColor = "#000"; + pip.appendChild(pipVideo); + + const pipLabel = document.createElement("span"); + pipLabel.dataset.cameraPipLabel = "true"; + pipLabel.textContent = "CAM"; + pipLabel.style.position = "absolute"; + pipLabel.style.top = "0.3rem"; + pipLabel.style.left = "0.3rem"; + pipLabel.style.padding = "0.15rem 0.5rem"; + pipLabel.style.borderRadius = "0.25rem"; + pipLabel.style.fontSize = "1.1rem"; + pipLabel.style.fontWeight = "600"; + pipLabel.style.color = "#1a1a1a"; + pipLabel.style.backgroundColor = "rgba(123, 226, 123, 0.82)"; + pipLabel.style.pointerEvents = "none"; + pipLabel.style.letterSpacing = "0.05em"; + pip.appendChild(pipLabel); + + container.appendChild(pip); + makePipDraggable(pip, container); + + return { container, label }; +}; + +export const makePipDraggable = ( + pip: HTMLElement, + parent: HTMLElement +): (() => void) => { + let dragging = false; + let startClientX = 0; + let startClientY = 0; + let startLeft = 0; + let startTop = 0; + + const switchToPixelPosition = () => { + const pipRect = pip.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + startLeft = pipRect.left - parentRect.left; + startTop = pipRect.top - parentRect.top; + pip.style.left = `${startLeft}px`; + pip.style.top = `${startTop}px`; + pip.style.right = "auto"; + pip.style.bottom = "auto"; + }; + + const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + + const onPointerDown = (e: PointerEvent) => { + if (e.button !== 0 && e.pointerType === "mouse") return; + dragging = true; + switchToPixelPosition(); + startClientX = e.clientX; + startClientY = e.clientY; + pip.setPointerCapture(e.pointerId); + pip.style.cursor = "grabbing"; + e.preventDefault(); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!dragging) return; + const parentRect = parent.getBoundingClientRect(); + const pipRect = pip.getBoundingClientRect(); + const dx = e.clientX - startClientX; + const dy = e.clientY - startClientY; + const maxLeft = parentRect.width - pipRect.width; + const maxTop = parentRect.height - pipRect.height; + const nextLeft = clamp(startLeft + dx, 0, Math.max(0, maxLeft)); + const nextTop = clamp(startTop + dy, 0, Math.max(0, maxTop)); + pip.style.left = `${nextLeft}px`; + pip.style.top = `${nextTop}px`; + }; + + const endDrag = (e: PointerEvent) => { + if (!dragging) return; + dragging = false; + try { + pip.releasePointerCapture(e.pointerId); + } catch { + // ignore — pointer may have already been released + } + pip.style.cursor = "grab"; + }; + + pip.style.cursor = "grab"; + pip.style.touchAction = "none"; + pip.addEventListener("pointerdown", onPointerDown); + pip.addEventListener("pointermove", onPointerMove); + pip.addEventListener("pointerup", endDrag); + pip.addEventListener("pointercancel", endDrag); + + return () => { + pip.removeEventListener("pointerdown", onPointerDown); + pip.removeEventListener("pointermove", onPointerMove); + pip.removeEventListener("pointerup", endDrag); + pip.removeEventListener("pointercancel", endDrag); + }; +}; + +export const setTilePipStream = ( + container: HTMLElement, + stream: MediaStream | null +) => { + const pipVideo = container.querySelector( + "[data-camera-pip] video" + ); + if (!pipVideo) return; + if (pipVideo.srcObject === stream) return; + pipVideo.srcObject = stream; + if (stream) pipVideo.play().catch(() => {}); +}; + +export const setTilePipLabel = ( + container: HTMLElement, + label: string | null +) => { + const pipLabel = container.querySelector( + "[data-camera-pip] [data-camera-pip-label]" + ); + if (!pipLabel) return; + pipLabel.textContent = label && label.trim() ? label : "CAM"; +}; + +export const setTilePinTransitioning = ( + container: HTMLElement, + on: boolean +) => { + const overlay = container.querySelector( + "[data-pin-transition-overlay]" + ); + if (!overlay) return; + overlay.style.display = on ? "flex" : "none"; +}; + +export const updateVideoTileLabel = ( + video: HTMLVideoElement, + name: string | null, + isWhip: boolean +) => { + const container = video.parentElement; + if (!container) return; + const label = container.querySelector( + "[data-video-tile-label]" + ); + if (!label) return; + if (!name) { + label.style.display = "none"; + label.textContent = ""; + return; + } + label.textContent = name; + label.style.backgroundColor = isWhip ? WHIP_COLOR : USER_COLOR; + label.style.display = "inline-block"; +}; + +export const updateVideoTileContainerPinned = ( + container: HTMLElement, + isPinned: boolean +) => { + const btn = container.querySelector( + "[data-tile-options-btn]" + ); + if (!btn) return; + container.dataset.pinned = isPinned ? "true" : ""; + btn.style.background = isPinned + ? OPTIONS_BTN_BG_PINNED + : OPTIONS_BTN_BG_DEFAULT; + btn.style.opacity = isPinned ? "1" : "0.7"; +}; + +const MEDIA_POLL_MS = 500; +const frameMonitorCleanups = new WeakMap void>(); +const participantBacked = new WeakMap(); +const tileContainers = new WeakMap(); + +export const setVideoTileVisible = ( + container: HTMLElement, + visible: boolean +): void => { + const next = visible ? "block" : "none"; + if (container.style.display === next) return; + container.style.display = next; +}; + +const shouldTileBeVisible = (videoElement: HTMLVideoElement): boolean => { + if (participantBacked.get(videoElement) === false) return false; + if (!(videoElement.videoWidth > 0 && videoElement.videoHeight > 0)) { + return false; + } + const tracks = + videoElement.srcObject instanceof MediaStream + ? videoElement.srcObject.getVideoTracks() + : []; + return !( + tracks.length > 0 && tracks.every((t) => t.muted || t.readyState !== "live") + ); +}; + +const syncTileVisibility = (videoElement: HTMLVideoElement): void => { + const container = tileContainers.get(videoElement); + if (!container) return; + setVideoTileVisible(container, shouldTileBeVisible(videoElement)); +}; + +export const setTileParticipantBacked = ( + videoElement: HTMLVideoElement, + backed: boolean +): void => { + participantBacked.set(videoElement, backed); + syncTileVisibility(videoElement); +}; + +const VISIBILITY_EVENTS = [ + "loadedmetadata", + "resize", + "playing", + "canplay", +] as const; + +export const attachShowWhenReady = ( + videoElement: HTMLVideoElement, + container: HTMLElement +): void => { + frameMonitorCleanups.get(videoElement)?.(); + tileContainers.set(videoElement, container); + + let aborted = false; + const sync = () => { + if (aborted) return; + syncTileVisibility(videoElement); + }; + + VISIBILITY_EVENTS.forEach((e) => videoElement.addEventListener(e, sync)); + const poll = setInterval(sync, MEDIA_POLL_MS); + sync(); + + frameMonitorCleanups.set(videoElement, () => { + aborted = true; + clearInterval(poll); + VISIBILITY_EVENTS.forEach((e) => videoElement.removeEventListener(e, sync)); + frameMonitorCleanups.delete(videoElement); + }); +}; + +export const stopFrameMonitor = (videoElement: HTMLVideoElement): void => { + frameMonitorCleanups.get(videoElement)?.(); +}; + +export const createVideoElement = ({ + stream, + lineId, + endpointId = null, +}: VideoElementOptions): HTMLVideoElement => { + const { container, label } = createVideoTileContainer(); + + const videoElement = document.createElement("video"); + + videoElement.id = `rtc-video-${lineId}-${Date.now()}`; + videoElement.autoplay = true; + videoElement.playsInline = true; + videoElement.muted = true; + videoElement.srcObject = stream; + + videoElement.dataset.endpointId = endpointId ?? ""; + + videoElement.style.width = "100%"; + videoElement.style.height = "100%"; + videoElement.style.aspectRatio = "16 / 9"; + videoElement.style.objectFit = "cover"; + videoElement.style.backgroundColor = "#000"; + videoElement.style.display = "block"; + + label.style.display = "none"; + + container.appendChild(videoElement); + + container.style.display = "none"; + attachShowWhenReady(videoElement, container); + + videoElement.play().catch(() => {}); + + return videoElement; +}; diff --git a/src/components/production-line/video-options-dialogue.tsx b/src/components/production-line/video-options-dialogue.tsx new file mode 100644 index 00000000..82ce0a5d --- /dev/null +++ b/src/components/production-line/video-options-dialogue.tsx @@ -0,0 +1,55 @@ +import styled from "@emotion/styled"; + +type VideoOptionsDialogueProps = { + isPinned?: boolean; + isWhepSource?: boolean; + onPin: () => void; + onSelectAsWhep?: () => void; +}; + +const VideoOptsDialogue = styled.div` + background-color: #2b2c2e; + padding: 0.5rem; + min-width: 10rem; + border-radius: 0.8rem; + display: flex; + flex-direction: column; + box-shadow: 0 0.4rem 1.6rem rgba(0, 0, 0, 0.5); + border: 0.1rem solid rgba(255, 255, 255, 0.08); +`; + +const OptionButton = styled.button` + background: none; + border: none; + color: #fff; + font-size: 1.4rem; + text-align: left; + padding: 0.6rem 0.75rem; + border-radius: 0.4rem; + cursor: pointer; + width: 100%; + + &:hover { + background-color: rgba(255, 255, 255, 0.5); + } +`; + +export const VideoOptionsDialogue = ({ + isPinned, + isWhepSource, + onPin, + onSelectAsWhep, +}: VideoOptionsDialogueProps) => { + return ( + + + {isPinned ? "Unpin video" : "Pin video"} + + {onSelectAsWhep && ( + + {isWhepSource ? "Unset as WHEP" : "Set as WHEP"} + + )} + + ); +}; diff --git a/src/components/production-list/line-block.tsx b/src/components/production-list/line-block.tsx index 6c47d521..cac53be4 100644 --- a/src/components/production-list/line-block.tsx +++ b/src/components/production-list/line-block.tsx @@ -16,6 +16,7 @@ import { TVIcon, UserIcon, UsersIcon, + VideoOnIcon, WhipIcon, } from "../../assets/icons/icon"; @@ -39,6 +40,11 @@ export const LineBlock = ({ )} + {line.videoEnabled && ( + + + + )} {line.name.length > 40 ? `${line.name.slice(0, 40)}...` : line.name} diff --git a/src/components/production-list/manage-production-buttons.tsx b/src/components/production-list/manage-production-buttons.tsx index aadd42d2..922c96f0 100644 --- a/src/components/production-list/manage-production-buttons.tsx +++ b/src/components/production-list/manage-production-buttons.tsx @@ -55,6 +55,7 @@ interface ManageProductionButtonsProps { type Line = { name: string; programOutputLine: boolean; + videoEnabled: boolean; }; export const ManageProductionButtons: FC = ( @@ -91,6 +92,7 @@ export const ManageProductionButtons: FC = ( defaultValues: { name: "", programOutputLine: false, + videoEnabled: false, }, resetOptions: { keepDirtyValues: true, @@ -176,6 +178,7 @@ export const ManageProductionButtons: FC = ( setAddLineOpen(!addLineOpen); setValue("name", ""); setValue("programOutputLine", false); + setValue("videoEnabled", false); }; const validateUniqueLineName = (value: string) => { @@ -227,6 +230,25 @@ export const ManageProductionButtons: FC = ( to talk. Only the Audio Feed will be heard. + + ( + ) => + controllerField.onChange(e.target.checked) + } + /> + )} + /> + + When enabled, participants can optionally share their camera in + this line. + + - isProgramOutput ? "rgb(73, 67, 124)" : "rgb(77, 77, 77)"}; + background-color: ${({ + isProgramOutput, + isVideoEnabled, + }: { + isProgramOutput?: boolean; + isVideoEnabled?: boolean; + }) => { + if (isVideoEnabled) return "rgb(45, 62, 124)"; + if (isProgramOutput) return "rgb(73, 67, 124)"; + return "rgb(77, 77, 77)"; + }}; border-radius: 1rem; padding: 1rem; display: flex; @@ -71,7 +80,6 @@ export const LineBlockTexts = styled.div` export const LineBlockTitle = styled.div` font-weight: bold; font-size: 1.5rem; - margin-bottom: 0.2rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -83,6 +91,7 @@ export const LineBlockTitleWrapper = styled.div` gap: 0.5rem; min-width: 0; overflow: hidden; + margin-bottom: 0.2rem; &.management { margin-right: 1rem; @@ -218,6 +227,7 @@ export const IconWrapper = styled.div` svg { height: 100%; width: 100%; + fill: #e8eaed; } `; diff --git a/src/components/production-list/production-list-expanded-content.tsx b/src/components/production-list/production-list-expanded-content.tsx index 3e2684bd..8edbce76 100644 --- a/src/components/production-list/production-list-expanded-content.tsx +++ b/src/components/production-list/production-list-expanded-content.tsx @@ -109,6 +109,7 @@ export const ProductionListExpandedContent = ({ audioinput: userSettings?.audioinput, lineUsedForProgramOutput: getLineByLineId(lineId)?.programOutputLine || false, + videoEnabled: getLineByLineId(lineId)?.videoEnabled ?? false, isProgramUser: false, }; @@ -148,6 +149,7 @@ export const ProductionListExpandedContent = ({ { - if (l.programOutputLine) { + if (l.programOutputLine || l.videoEnabled) { navigate( buildCallsUrl([ { diff --git a/src/components/shared/collapsible-item.tsx b/src/components/shared/collapsible-item.tsx index effb26c6..db99b076 100644 --- a/src/components/shared/collapsible-item.tsx +++ b/src/components/shared/collapsible-item.tsx @@ -17,21 +17,21 @@ const CollapsibleHeaderTexts = styled(HeaderTexts)` type CollapsibleItemProps = { headerContent: ReactNode; expandedContent: ReactNode; + className?: string; + testId?: string; onHeaderClick?: ( e: React.MouseEvent, open: boolean, setOpen: (open: boolean) => void ) => void; - className?: string; - testId?: string; }; export const CollapsibleItem = ({ headerContent, expandedContent, - onHeaderClick, className, testId, + onHeaderClick, }: CollapsibleItemProps) => { const [open, setOpen] = useState(false); diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx index 2b73dcb6..c1e75410 100644 --- a/src/components/tooltip/tooltip.tsx +++ b/src/components/tooltip/tooltip.tsx @@ -1,9 +1,10 @@ import styled from "@emotion/styled"; +import { useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; const IconWrapper = styled.div` display: flex; align-items: center; - cursor: pointer; svg { fill: grey; @@ -33,18 +34,12 @@ const IconWrapper = styled.div` const TooltipWrapper = styled.div` position: relative; display: inline-block; - cursor: pointer; - - &:hover span { - opacity: 1; - visibility: visible; - } `; -const TooltipText = styled.span` - position: absolute; - bottom: 125%; - left: 50%; +const Popup = styled.span<{ x: number; y: number; placeAbove: boolean }>` + position: fixed; + left: ${({ x }) => x}px; + ${({ y, placeAbove }) => (placeAbove ? `bottom: ${y}px;` : `top: ${y}px;`)} transform: translateX(-50%); background-color: #333; color: #fff; @@ -52,22 +47,11 @@ const TooltipText = styled.span` border-radius: 0.4rem; font-size: 1.2rem; white-space: normal; - opacity: 0; - visibility: hidden; - transition: all 0.2s ease; + width: max-content; + max-width: 22rem; + text-align: center; pointer-events: none; - z-index: 10; - - &.collapsable-header, - &.firefox-warning { - bottom: -9rem; - left: 0; - background-color: #676767; - line-height: 1.5; - white-space: normal; - transform: none; - width: 26rem; - } + z-index: 1000; `; export const Tooltip = ({ @@ -79,11 +63,40 @@ export const Tooltip = ({ tooltipText: string | React.ReactNode; type?: "collapsable-header" | "firefox-warning"; }) => { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const [pos, setPos] = useState({ x: 0, y: 0, placeAbove: true }); + + useLayoutEffect(() => { + if (!visible || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const margin = 8; + const idealX = rect.left + rect.width / 2; + const placeAbove = rect.top > 60; + setPos({ + x: idealX, + y: placeAbove + ? window.innerHeight - rect.top + margin + : rect.bottom + margin, + placeAbove, + }); + }, [visible]); + return ( - + setVisible(true)} + onMouseLeave={() => setVisible(false)} + > {children} - {tooltipText} + {visible && + createPortal( + + {tooltipText} + , + document.body + )} ); diff --git a/src/components/user-settings-form/use-submit-form.tsx b/src/components/user-settings-form/use-submit-form.tsx index 1a6caf1c..37e3b1d2 100644 --- a/src/components/user-settings-form/use-submit-form.tsx +++ b/src/components/user-settings-form/use-submit-form.tsx @@ -56,6 +56,7 @@ export const useSubmitForm = ({ username: payload.username || userSettings?.username || "", audioinput: resolvedAudioInput, lineUsedForProgramOutput: selectedLine?.programOutputLine || false, + videoEnabled: selectedLine?.videoEnabled ?? false, isProgramUser: isProgramUser || false, lineName: selectedLineName || selectedLine?.name, productionName: productionName || production?.name, diff --git a/src/components/user-settings-form/user-settings-form.test.tsx b/src/components/user-settings-form/user-settings-form.test.tsx index 9e8a74a3..0c6af82c 100644 --- a/src/components/user-settings-form/user-settings-form.test.tsx +++ b/src/components/user-settings-form/user-settings-form.test.tsx @@ -54,7 +54,7 @@ const mockState: TGlobalState = { reloadPresetList: false, production: null, selectedProductionId: null, - devices: { input: [], output: [] }, + devices: { input: [], output: [], videoInput: [] }, userSettings: null, apiError: false, websocket: null, @@ -218,3 +218,102 @@ describe("UserSettingsForm — lineId auto-selection fix", () => { expect(lineSelect.value).toBe("line-1"); }); }); + +// ── Camera select for video-enabled lines ──────────────────────────────────── + +const productionWithVideoLine = { + name: "Video Show", + productionId: "prod-2", + lines: [ + { + id: "line-1", + name: "Video Line", + participants: [], + smbConferenceId: "", + videoEnabled: true, + }, + ], +}; + +describe("UserSettingsForm — camera select on video-enabled lines", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockUseGlobalState.mockReturnValue([ + { + ...mockState, + devices: { + input: [], + output: [], + videoInput: [ + { + deviceId: "cam-1", + label: "Built-in Camera", + groupId: "group-1", + kind: "videoinput", + toJSON: () => ({}), + } as MediaDeviceInfo, + ], + }, + }, + mockDispatch, + ]); + mockUseSubmitForm.mockReturnValue({ onSubmit: vi.fn() }); + }); + + const selectProduction = async (productionId: string) => { + await act(async () => { + render( + + ); + }); + + await act(async () => { + fireEvent.change(screen.getAllByRole("combobox")[0], { + target: { value: productionId }, + }); + }); + }; + + it("offers camera selection when the selected line is video enabled", async () => { + mockUseFetchProductionList.mockReturnValue({ + productions: { + ...productionList, + productions: [productionWithVideoLine], + }, + doInitialLoad: false, + error: null, + setIntervalLoad: vi.fn(), + }); + + await selectProduction(productionWithVideoLine.productionId); + + const cameraSelect = screen.getByRole("combobox", { name: "Camera" }); + expect(cameraSelect).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Built-in Camera" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "No camera" }) + ).toBeInTheDocument(); + }); + + it("does not offer camera selection when the selected line is audio only", async () => { + mockUseFetchProductionList.mockReturnValue({ + productions: productionList, + doInitialLoad: false, + error: null, + setIntervalLoad: vi.fn(), + }); + + await selectProduction(productionWithLines.productionId); + + expect( + screen.queryByRole("combobox", { name: "Camera" }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/user-settings-form/user-settings-form.tsx b/src/components/user-settings-form/user-settings-form.tsx index 24ceeb02..b8b8c459 100644 --- a/src/components/user-settings-form/user-settings-form.tsx +++ b/src/components/user-settings-form/user-settings-form.tsx @@ -120,22 +120,21 @@ export const UserSettingsForm = ({ }; const { productions: fetchedProductions, error: productionListFetchError } = useFetchProductionList(productionListFilter); - - // Use prefetched list immediately (no loading flicker), then switch to the - // live-fetched list once it arrives. const productions = fetchedProductions ?? prefetchedProductionList; - // When a pre-fetched production arrives (via prop), adopt it immediately so - // the line dropdown renders without waiting for the full production list. useEffect(() => { if (prefetchedProduction) { setProduction(prefetchedProduction); } }, [prefetchedProduction]); - // this will update whenever lineId changes const selectedLineId = useWatch({ name: "lineId", control }); + const selectedLine = production?.lines.find( + (l) => l.id.toString() === selectedLineId + ); + const selectedLineVideoEnabled = selectedLine?.videoEnabled ?? false; + const [{ devices, selectedProductionId: globalSelectedProductionId, calls }] = useGlobalState(); @@ -164,12 +163,12 @@ export const UserSettingsForm = ({ useEffect(() => { if (production && isJoinProduction) { - const selectedLine = production.lines.find( + const matchedLine = production.lines.find( (line) => line.id.toString() === selectedLineId ); - setSelectedLineName(selectedLine?.name ?? ""); - setIsProgramOutputLine(!!selectedLine?.programOutputLine); - if (!selectedLine?.programOutputLine) { + setSelectedLineName(matchedLine?.name ?? ""); + setIsProgramOutputLine(!!matchedLine?.programOutputLine); + if (!matchedLine?.programOutputLine) { setIsProgramUser?.(false); } } @@ -400,6 +399,24 @@ export const UserSettingsForm = ({ )} )} + {isJoinProduction && selectedLineVideoEnabled && ( + + + + {devices.videoInput && devices.videoInput.length > 0 + ? devices.videoInput.map((device) => ( + + )) + : null} + + + )} {isProgramOutputLine && isJoinProduction && ( void; onConfirm: () => void; diff --git a/src/global-state/global-state-reducer.test.ts b/src/global-state/global-state-reducer.test.ts index 1164b3d7..d351d61b 100644 --- a/src/global-state/global-state-reducer.test.ts +++ b/src/global-state/global-state-reducer.test.ts @@ -20,10 +20,12 @@ function createMockCallState(overrides: Partial = {}): CallState { return { joinProductionOptions: null, mediaStreamInput: null, + mediaStreamVideoInput: null, dominantSpeaker: null, audioLevelAboveThreshold: false, connectionState: null, audioElements: null, + videoElements: null, sessionId: null, hotkeys: mockHotkeys, dataChannel: null, @@ -58,7 +60,7 @@ describe("globalReducer", () => { error: { callErrors: null, globalError: null, globalWarning: null }, reloadProductionList: false, reloadPresetList: false, - devices: { input: null, output: null }, + devices: { input: null, output: null, videoInput: null }, userSettings: {}, selectedProductionId: null, calls: {}, @@ -204,6 +206,7 @@ describe("globalReducer", () => { const devices = { input: [{ deviceId: "mic-1" } as MediaDeviceInfo], output: [{ deviceId: "spk-1" } as MediaDeviceInfo], + videoInput: null, }; const action: TGlobalStateAction = { type: "DEVICES_UPDATED", @@ -488,7 +491,7 @@ describe("globalReducer", () => { { type: "PRODUCTION_LIST_FETCHED" }, { type: "DEVICES_UPDATED", - payload: { input: null, output: null }, + payload: { input: null, output: null, videoInput: null }, }, { type: "SELECT_PRODUCTION_ID", payload: "x" }, { diff --git a/src/global-state/global-state-reducer.ts b/src/global-state/global-state-reducer.ts index baa7e39c..f43365b5 100644 --- a/src/global-state/global-state-reducer.ts +++ b/src/global-state/global-state-reducer.ts @@ -10,6 +10,7 @@ export const initialGlobalState: TGlobalState = { devices: { input: null, output: null, + videoInput: null, }, userSettings: {}, selectedProductionId: null, @@ -22,24 +23,23 @@ export const globalReducer: Reducer = ( state, action ): TGlobalState => { - // Simple Debug - // logger.cyan( - // `Global state action: ${action.type}, payload: ${action.payload}` - // ); switch (action.type) { case "ERROR": { const { callId, error } = action.payload; - if (callId && error) { + if (callId) { // Call-specific error + const callErrors = { ...state.error.callErrors }; + if (error) { + callErrors[callId] = error; + } else { + delete callErrors[callId]; + } return { ...state, error: { ...state.error, - callErrors: { - ...state.error.callErrors, - [callId]: error, - }, + callErrors, }, }; } diff --git a/src/global-state/types.ts b/src/global-state/types.ts index 4e03cd3c..b6bceba2 100644 --- a/src/global-state/types.ts +++ b/src/global-state/types.ts @@ -14,6 +14,7 @@ export interface ErrorState { export interface DevicesState { input: MediaDeviceInfo[] | null; output: MediaDeviceInfo[] | null; + videoInput: MediaDeviceInfo[] | null; } export interface CallState { @@ -21,10 +22,12 @@ export interface CallState { // Not all devices allow choosing output audiooutput?: string; mediaStreamInput: MediaStream | null; + mediaStreamVideoInput: MediaStream | null; dominantSpeaker: string | null; audioLevelAboveThreshold: boolean; connectionState: RTCPeerConnectionState | null; audioElements: HTMLAudioElement[] | null; + videoElements: HTMLVideoElement[] | null; sessionId: string | null; hotkeys: Hotkeys; dataChannel: RTCDataChannel | null; diff --git a/src/hooks/use-fetch-devices.ts b/src/hooks/use-fetch-devices.ts index bd63126c..aba9c4c9 100644 --- a/src/hooks/use-fetch-devices.ts +++ b/src/hooks/use-fetch-devices.ts @@ -12,7 +12,7 @@ export const useFetchDevices = ({ permission, dispatch }: TUseFetchDevices) => { // Create a function that returns a promise const getUpdatedDevices = useCallback(async () => { if (!permission) { - return { input: [], output: [] }; + return { input: [], output: [], videoInput: [] }; } try { @@ -32,9 +32,17 @@ export const useFetchDevices = ({ permission, dispatch }: TUseFetchDevices) => { ) : []; + const videoInputDevices = devices + ? uniqBy( + devices.filter((d) => d.kind === "videoinput"), + (item) => item.deviceId + ) + : []; + const result = { input: inputDevices, output: outputDevices, + videoInput: videoInputDevices, }; dispatch({ diff --git a/src/hooks/use-initiate-production-call.test.ts b/src/hooks/use-initiate-production-call.test.ts index 84e40bcd..dbea32e1 100644 --- a/src/hooks/use-initiate-production-call.test.ts +++ b/src/hooks/use-initiate-production-call.test.ts @@ -206,7 +206,7 @@ describe("useInitiateProductionCall", () => { // ── Output device not found (non-Safari) ───────────────────────────────── describe("output device not found on non-Safari browser", () => { - it("returns false and dispatches ERROR when output device is missing", async () => { + it("falls back to first available output device when stored device is missing", async () => { mockGetUpdatedDevices.mockResolvedValue({ input: [makeDevice("mic1")], output: [makeOutputDevice("other-spk")], @@ -223,6 +223,36 @@ describe("useInitiateProductionCall", () => { }); }); + expect(returnValue).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "ADD_CALL", + payload: expect.objectContaining({ + callState: expect.objectContaining({ + audiooutput: "other-spk", + }), + }), + }) + ); + }); + + it("returns false and dispatches ERROR when no output devices exist at all", async () => { + mockGetUpdatedDevices.mockResolvedValue({ + input: [makeDevice("mic1")], + output: [], + }); + + const { result } = renderHook(() => + useInitiateProductionCall({ dispatch: mockDispatch }) + ); + + let returnValue: boolean | undefined; + await act(async () => { + returnValue = await result.current.initiateProductionCall({ + payload: defaultPayload, + }); + }); + expect(returnValue).toBe(false); const dispatchMock2 = mockDispatch as unknown as ReturnType; const errorCall = dispatchMock2.mock.calls.find( diff --git a/src/hooks/use-initiate-production-call.tsx b/src/hooks/use-initiate-production-call.tsx index 8f6e72ed..880a6cd8 100644 --- a/src/hooks/use-initiate-production-call.tsx +++ b/src/hooks/use-initiate-production-call.tsx @@ -42,6 +42,7 @@ export const useInitiateProductionCall = ({ // If the stored input device is no longer available, fall back to // the first available device rather than blocking the user. let effectiveAudioInput = payload.joinProductionOptions.audioinput; + let effectiveAudioOutput = payload.audiooutput; if (updatedDevices.input.length > 0) { const inputDeviceExists = updatedDevices.input.some( @@ -53,17 +54,21 @@ export const useInitiateProductionCall = ({ } const outputDeviceExists = updatedDevices.output.some( - (device) => device.deviceId === payload.audiooutput + (device) => device.deviceId === effectiveAudioOutput ); if (!outputDeviceExists && !isBrowserSafari && !isMobile && !isIpad) { - dispatch({ - type: "ERROR", - payload: { - error: new Error("Selected devices are not available"), - }, - }); - return false; + if (updatedDevices.output.length > 0) { + effectiveAudioOutput = updatedDevices.output[0].deviceId; + } else { + dispatch({ + type: "ERROR", + payload: { + error: new Error("Selected devices are not available"), + }, + }); + return false; + } } } @@ -78,12 +83,14 @@ export const useInitiateProductionCall = ({ ...payload.joinProductionOptions, audioinput: effectiveAudioInput, }, - audiooutput: payload.audiooutput, + audiooutput: effectiveAudioOutput, mediaStreamInput: null, + mediaStreamVideoInput: null, dominantSpeaker: null, audioLevelAboveThreshold: false, connectionState: null, audioElements: null, + videoElements: null, sessionId: null, dataChannel: null, isRemotelyMuted: false, diff --git a/src/hooks/use-video-device-labels.test.ts b/src/hooks/use-video-device-labels.test.ts new file mode 100644 index 00000000..20257ff5 --- /dev/null +++ b/src/hooks/use-video-device-labels.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useVideoDeviceLabels } from "./use-video-device-labels.ts"; + +const device = (kind: MediaDeviceKind, deviceId: string, label = "") => + ({ kind, deviceId, label, groupId: "" }) as MediaDeviceInfo; + +const labelled = [ + device("audioinput", "mic-1", "Mic"), + device("audiooutput", "spk-1", "Speaker"), + device("videoinput", "cam-1", "Camera"), +]; + +const setup = (enumerateResults: MediaDeviceInfo[][]) => { + const track = { stop: vi.fn() }; + const enumerateDevices = vi.fn(); + enumerateResults.forEach((r) => enumerateDevices.mockResolvedValueOnce(r)); + enumerateDevices.mockResolvedValue(enumerateResults.at(-1)); + const listeners = new Set<() => void>(); + const mediaDevices = { + enumerateDevices, + getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [track] }), + addEventListener: vi.fn((_: string, cb: () => void) => listeners.add(cb)), + removeEventListener: vi.fn((_: string, cb: () => void) => + listeners.delete(cb) + ), + }; + vi.stubGlobal("navigator", { mediaDevices }); + const fireDeviceChange = () => listeners.forEach((cb) => cb()); + return { mediaDevices, track, listeners, fireDeviceChange }; +}; + +describe("useVideoDeviceLabels", () => { + const dispatch = vi.fn(); + + beforeEach(() => { + dispatch.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does nothing when disabled", () => { + const { mediaDevices } = setup([labelled]); + + renderHook(() => useVideoDeviceLabels({ enabled: false, dispatch })); + + expect(mediaDevices.enumerateDevices).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("dispatches devices without opening the camera when labels are readable", async () => { + const { mediaDevices } = setup([labelled]); + + renderHook(() => useVideoDeviceLabels({ enabled: true, dispatch })); + + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); + expect(mediaDevices.getUserMedia).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "DEVICES_UPDATED", + payload: { + input: [labelled[0]], + output: [labelled[1]], + videoInput: [labelled[2]], + }, + }); + }); + + it("warms up the camera to unlock labels and stops the stream", async () => { + const { mediaDevices, track } = setup([ + [device("videoinput", "")], + labelled, + ]); + + renderHook(() => useVideoDeviceLabels({ enabled: true, dispatch })); + + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); + expect(mediaDevices.getUserMedia).toHaveBeenCalledWith({ + video: true, + audio: false, + }); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].payload.videoInput).toEqual([labelled[2]]); + }); + + it("does not dispatch when the camera warm-up is rejected", async () => { + const { mediaDevices } = setup([[device("videoinput", "")]]); + mediaDevices.getUserMedia.mockRejectedValue(new Error("denied")); + + renderHook(() => useVideoDeviceLabels({ enabled: true, dispatch })); + + await waitFor(() => + expect(mediaDevices.getUserMedia).toHaveBeenCalledTimes(1) + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("refreshes the list when devices change", async () => { + const extraCamera = device("videoinput", "cam-2", "USB Camera"); + const { fireDeviceChange } = setup([labelled, [...labelled, extraCamera]]); + + renderHook(() => useVideoDeviceLabels({ enabled: true, dispatch })); + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); + + fireDeviceChange(); + + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)); + expect(dispatch.mock.calls[1][0].payload.videoInput).toEqual([ + labelled[2], + extraCamera, + ]); + }); + + it("removes the devicechange listener on unmount", async () => { + const { listeners } = setup([labelled]); + + const { unmount } = renderHook(() => + useVideoDeviceLabels({ enabled: true, dispatch }) + ); + expect(listeners.size).toBe(1); + + unmount(); + + expect(listeners.size).toBe(0); + }); + + it("warms up again on a new mount (no module-level state)", async () => { + const first = setup([[device("videoinput", "")], labelled]); + const { unmount } = renderHook(() => + useVideoDeviceLabels({ enabled: true, dispatch }) + ); + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); + unmount(); + expect(first.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + + const second = setup([[device("videoinput", "")], labelled]); + renderHook(() => useVideoDeviceLabels({ enabled: true, dispatch })); + + await waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)); + expect(second.mediaDevices.getUserMedia).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/use-video-device-labels.ts b/src/hooks/use-video-device-labels.ts new file mode 100644 index 00000000..e59a6756 --- /dev/null +++ b/src/hooks/use-video-device-labels.ts @@ -0,0 +1,70 @@ +import { Dispatch, useEffect } from "react"; +import { TGlobalStateAction } from "../global-state/global-state-actions"; +import { noop, uniqBy } from "../helpers"; +import logger from "../utils/logger"; + +type TUseVideoDeviceLabels = { + enabled: boolean; + dispatch: Dispatch; +}; + +const byKind = (devices: MediaDeviceInfo[], kind: MediaDeviceKind) => + uniqBy( + devices.filter((d) => d.kind === kind), + (d) => d.deviceId + ); + +const needsCameraWarmUp = (devices: MediaDeviceInfo[]) => { + const cameras = devices.filter((d) => d.kind === "videoinput"); + return cameras.length === 0 || cameras.some((d) => !d.label); +}; + +export const useVideoDeviceLabels = ({ + enabled, + dispatch, +}: TUseVideoDeviceLabels) => { + useEffect(() => { + if (!enabled) return noop; + + let cancelled = false; + const { mediaDevices } = navigator; + + const refresh = async () => { + let devices = await mediaDevices.enumerateDevices(); + + if (needsCameraWarmUp(devices)) { + const stream = await mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + stream.getTracks().forEach((t) => t.stop()); + devices = await mediaDevices.enumerateDevices(); + } + + if (cancelled) return; + + dispatch({ + type: "DEVICES_UPDATED", + payload: { + input: byKind(devices, "audioinput"), + output: byKind(devices, "audiooutput"), + videoInput: byKind(devices, "videoinput"), + }, + }); + }; + + const onDeviceChange = () => { + refresh().catch((err) => { + logger.yellow(`[useVideoDeviceLabels] refresh failed: ${err}`); + }); + }; + + onDeviceChange(); + mediaDevices.addEventListener("devicechange", onDeviceChange); + + return () => { + cancelled = true; + mediaDevices.removeEventListener("devicechange", onDeviceChange); + }; + }, [enabled, dispatch]); +};