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 0a573f42..5e60937c 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -24,7 +24,11 @@ export type TPreset = { type TCreateProductionOptions = { name: string; - lines: { name: string; programOutputLine?: boolean }[]; + lines: { + name: string; + programOutputLine?: boolean; + videoEnabled?: boolean; + }[]; }; type TParticipant = { @@ -33,6 +37,8 @@ type TParticipant = { endpointId: string; isActive: boolean; isWhip: boolean; + isWhepReceiver?: boolean; + hasVideo: boolean; }; type TLine = { @@ -41,6 +47,8 @@ type TLine = { smbConferenceId: string; participants: TParticipant[]; programOutputLine?: boolean; + videoEnabled?: boolean; + whepSourceSessionId?: string | null; }; export type TBasicProductionResponse = { @@ -155,6 +163,47 @@ 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", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + 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", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ pinnedSessionId }), + }) + ), listProductions: ({ searchParams, }: { @@ -227,7 +276,8 @@ export const API = { addProductionLine: ( productionId: string, name: string, - programOutputLine?: boolean + programOutputLine?: boolean, + videoEnabled?: boolean ): Promise => handleFetchRequest( fetch(`${API_URL}production/${productionId}/line`, { @@ -239,6 +289,7 @@ export const API = { body: JSON.stringify({ name, programOutputLine, + videoEnabled, }), }) ), @@ -369,6 +420,7 @@ export const API = { body: JSON.stringify(options), }) ), + listPresets: (): Promise<{ presets: TPreset[] }> => handleFetchRequest<{ presets: TPreset[] }>( fetch(`${API_URL}preset`, { @@ -378,6 +430,7 @@ export const API = { }, }) ), + deletePreset: async (id: string): Promise => { const response = await fetch(`${API_URL}preset/${id}`, { method: "DELETE", @@ -389,6 +442,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 7f844542..577a12e9 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -30,6 +30,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"; @@ -137,6 +138,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, @@ -154,6 +165,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(); @@ -277,6 +289,7 @@ export const CallsPage = () => { ); const program: PendingProgramLine[] = []; + const video: PendingVideoLine[] = []; const validRefs: typeof pendingCallRefs = []; pendingCallRefs.forEach((ref, i) => { @@ -294,6 +307,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, + }); } }); @@ -304,6 +324,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, @@ -330,6 +357,7 @@ export const CallsPage = () => { autoJoinTriggeredRef.current = true; const programKeys = pendingProgramKeysRef.current; + const videoKeys = pendingVideoKeysRef.current; const existingKeys = new Set( Object.values(calls) @@ -342,6 +370,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) => { @@ -553,6 +582,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..02c77881 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.div<{ disabled?: boolean }>` display: flex; align-items: center; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; + user-select: none; `; const CheckboxComponent = styled.input` @@ -41,21 +44,45 @@ const CheckboxComponent = styled.input` font-weight: bold; } - &:hover { + &:hover:not(:disabled) { background-color: rgba(89, 203, 232, 1); } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } `; -export const Checkbox = ({ label, checked, onChange }: TCheckboxProps) => { +const DisabledLabel = styled.label<{ disabled?: boolean }>` + ${({ disabled }) => (disabled ? "opacity: 0.45; cursor: not-allowed;" : "")} +`; + +export const Checkbox = ({ + label, + checked, + disabled, + onChange, +}: TCheckboxProps) => { + const toggle = () => { + if (disabled) return; + const next = !checked; + onChange({ + target: { checked: next, type: "checkbox" }, + currentTarget: { checked: next, type: "checkbox" }, + } as unknown as React.ChangeEvent); + }; + return ( - + - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - + {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/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..ef23ce45 --- /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.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/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 02c55a8b..a005d394 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"; @@ -45,6 +53,14 @@ import { useUpdateCallDevice } from "./use-update-call-device.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 { 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-modal.tsx"; type TProductionLine = { id: string; @@ -91,7 +107,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(""); @@ -105,6 +134,7 @@ export const ProductionLine = ({ audioLevelAboveThreshold, connectionState, audioElements, + videoElements, sessionId, hotkeys: savedHotkeys, dataChannel, @@ -120,6 +150,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); @@ -155,6 +202,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 && @@ -168,6 +278,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, @@ -232,6 +374,13 @@ export const ProductionLine = ({ } }, [audioElements]); + useEffect(() => { + shadowAudioElements.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.volume = value; + }); + }, [shadowAudioElements, value]); + const { startTalking, stopTalking, @@ -304,6 +453,7 @@ export const ProductionLine = ({ const exit = useCallback(() => { setConnectionActive(false); playExitSound(); + resetVideoInput(); dispatch({ type: "REMOVE_CALL", payload: { id }, @@ -313,7 +463,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; @@ -370,6 +541,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; @@ -451,6 +632,184 @@ 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, + 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 pinnedRemoteStream = useMemo(() => { + const elements = videoElements ?? []; + if (elements.length === 0) 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]); + + const pinnedRemoteName = useMemo(() => { + 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]); + + const pinnedRemoteIsWhip = useMemo(() => { + 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]); + + 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; @@ -507,12 +866,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 && ( @@ -604,9 +1024,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} @@ -628,7 +1055,7 @@ export const ProductionLine = ({ )} )} -
+ {confirmModalOpen && ( @@ -648,6 +1075,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 f0d825a8..789e1633 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 { 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; }; @@ -18,6 +21,8 @@ export const SymphonyRtcConnectionComponent = ({ joinProductionOptions, audiooutput, inputAudioStream, + inputVideoStream, + videoEnabled, callId, dispatch, }: SymphonyRtcConnectionComponentProps) => { @@ -27,8 +32,10 @@ export const SymphonyRtcConnectionComponent = ({ dispatch, }); - const { connectionState, audioElements } = useRtcConnection({ + const { connectionState, audioElements, videoElements } = useRtcConnection({ inputAudioStream, + inputVideoStream, + videoEnabled, sdpOffer, joinProductionOptions, audiooutput, @@ -45,6 +52,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..d8b0da97 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 b990bed5..044f5243 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..9711dff1 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,53 @@ 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(() => {}); + setVideoElements((current) => [...current]); + }); + + if (selectedStream instanceof MediaStream) { + selectedStream.addEventListener("removetrack", (event) => { + if (event.track.id === track.id) { + removeThisElement(); + } + }); + } + + return [videoElement, ...prev]; }); } }; @@ -216,6 +299,8 @@ const establishConnection = ({ export const useRtcConnection = ({ inputAudioStream, + inputVideoStream, + videoEnabled, sdpOffer, joinProductionOptions, audiooutput, @@ -235,6 +320,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(); @@ -242,14 +328,15 @@ export const useRtcConnection = ({ if (noStreamError) { navigate("/"); } - }, [navigate, noStreamError]); + }, [navigate, noStreamError, callId]); useEffect(() => { if ( !sdpOffer || !sessionId || !joinProductionOptions || - !inputAudioStream + !inputAudioStream || + (videoEnabled && inputVideoStream === null) ) { return noop; } @@ -284,6 +371,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 +395,7 @@ export const useRtcConnection = ({ callId, dispatch, setAudioElements, + setVideoElements, setNoStreamError, }); @@ -310,6 +413,8 @@ export const useRtcConnection = ({ }, [ sdpOffer, inputAudioStream, + inputVideoStream, + videoEnabled, sessionId, joinProductionOptions, rtcPeerConnection, @@ -319,5 +424,5 @@ export const useRtcConnection = ({ useRtcDebugLogger(rtcPeerConnection); - return { connectionState, audioElements }; + return { connectionState, audioElements, videoElements }; }; 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..fa1a27e6 --- /dev/null +++ b/src/components/production-line/use-shadow-rtc-connection.ts @@ -0,0 +1,185 @@ +/** + * 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 () => { + // 1. Request an SDP offer for the shadow line. + const { sdp: sdpOffer, sessionId } = await API.offerAudioSession({ + productionId: parseInt(productionId, 10), + lineId: parseInt(lineId, 10), + 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.ts b/src/components/production-line/use-video-source-pin.ts new file mode 100644 index 00000000..0190780d --- /dev/null +++ b/src/components/production-line/use-video-source-pin.ts @@ -0,0 +1,85 @@ +import { useCallback, useRef } from "react"; +import { API } from "../../api/api.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(() => {}); + }, + [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.ts b/src/components/production-line/use-video-tile-grid.ts new file mode 100644 index 00000000..5e2f7923 --- /dev/null +++ b/src/components/production-line/use-video-tile-grid.ts @@ -0,0 +1,241 @@ +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import { LabelResult } from "./match-video-tile-labels.ts"; +import { TParticipant } from "./types.ts"; +import { + setTilePinTransitioning, + setTilePipStream, + setTilePipLabel, + updateVideoTileLabel, + updateVideoTileContainerPinned, +} from "./video-element-factory.ts"; + +type UseVideoTileGridArgs = { + videoElements: HTMLVideoElement[] | null; + participants: TParticipant[]; + tileMatches: LabelResult[]; + pinnedContainer: HTMLElement | null; + pinnedSessionId: 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, + cameraStream, + cameraLabel, + pendingPinPromiseRef, + onAutoUnpin, +}: UseVideoTileGridArgs): UseVideoTileGridResult => { + const [videoGridEl, setVideoGridEl] = useState(null); + + useEffect(() => { + const mainGrid = videoGridEl; + if (!mainGrid) return undefined; + + const elements = videoElements || []; + + const staleVideoElements = 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; + if (!tracked) return; + const liveBacking = participants.some( + (p) => p.sessionId === tracked && p.isActive && p.hasVideo + ); + if (liveBacking) return; + const trackAlive = + el.srcObject instanceof MediaStream && + el.srcObject.getVideoTracks().some((t) => t.readyState === "live"); + if (trackAlive) return; + staleVideoElements.add(el); + }); + + const liveElements = staleVideoElements.size + ? elements.filter((el) => !staleVideoElements.has(el)) + : elements; + const tiles = liveElements.map((el) => el.parentElement ?? el); + const currentTileSet = new Set(tiles); + + const pinnedParticipant = pinnedSessionId + ? (participants.find((p) => p.sessionId === pinnedSessionId) ?? null) + : null; + liveElements.forEach((videoEl) => { + const originalIdx = elements.indexOf(videoEl); + const match = tileMatches[originalIdx]; + 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, + 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 20450bc9..9e7e3fba 100644 --- a/src/components/production-line/user-list.tsx +++ b/src/components/production-line/user-list.tsx @@ -1,11 +1,7 @@ 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 { UserActionSlots } from "./user-action-slots.tsx"; const Container = styled.div` width: 100%; @@ -110,62 +106,20 @@ const OnlineIndicator = styled.div` } `; -const ParticipantControls = styled.div` - display: flex; - align-items: center; - flex-shrink: 0; -`; - -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; @@ -177,9 +131,14 @@ export const UserList = ({ dominantSpeaker, audioLevelAboveThreshold, programOutputLine, + videoEnabled, + pinnedVideoSessionId, + whepSourceSessionId, setConfirmModalOpen, setUserId, setUserName, + onPin, + onSetWhep, setKickModalOpen, setKickSessionId, setKickUserName, @@ -188,6 +147,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; + const getStatusClass = (isActive: boolean, isWhip: boolean) => { if (!isActive) return "inactive"; return isWhip ? "whip" : "user"; @@ -220,32 +184,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.ts b/src/components/production-line/video-element-factory.ts new file mode 100644 index 00000000..fdd6172e --- /dev/null +++ b/src/components/production-line/video-element-factory.ts @@ -0,0 +1,550 @@ +/* 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 FRAME_STALL_MS = 3000; + +const frameMonitorCleanups = new WeakMap void>(); + +export const attachShowWhenReady = ( + videoElement: HTMLVideoElement, + container: HTMLElement +): void => { + frameMonitorCleanups.get(videoElement)?.(); + + let aborted = false; + const setVisible = (visible: boolean) => { + const next = visible ? "block" : "none"; + if (container.style.display === next) return; + container.style.display = next; + }; + + type RVFCMetadata = { presentedFrames: number }; + type RVFC = (cb: (now: number, metadata: RVFCMetadata) => void) => number; + type CVFC = (handle: number) => void; + const rvfc = (videoElement as unknown as { requestVideoFrameCallback?: RVFC }) + .requestVideoFrameCallback; + + // Without rVFC (older WebKit) we can't detect a stall — fall back to the + // original show-when-ready-only behaviour. + if (typeof rvfc !== "function") { + let shown = false; + const tryShow = () => { + if (shown) return; + if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + setVisible(true); + shown = true; + } + }; + tryShow(); + const onEvent = () => tryShow(); + videoElement.addEventListener("loadedmetadata", onEvent); + videoElement.addEventListener("resize", onEvent); + videoElement.addEventListener("playing", onEvent); + videoElement.addEventListener("canplay", onEvent); + frameMonitorCleanups.set(videoElement, () => { + aborted = true; + videoElement.removeEventListener("loadedmetadata", onEvent); + videoElement.removeEventListener("resize", onEvent); + videoElement.removeEventListener("playing", onEvent); + videoElement.removeEventListener("canplay", onEvent); + frameMonitorCleanups.delete(videoElement); + }); + return; + } + + let lastFrameAt = performance.now(); + let lastMediaTime = videoElement.currentTime; + let lastMediaAdvanceAt = performance.now(); + let frameHandle: number | null = null; + const onFrame = () => { + if (aborted) return; + lastFrameAt = performance.now(); + if (videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + setVisible(true); + } + frameHandle = rvfc.call(videoElement, onFrame); + }; + frameHandle = rvfc.call(videoElement, onFrame); + + const stallTimer = setInterval(() => { + if (aborted) return; + const now = performance.now(); + if (videoElement.currentTime > lastMediaTime) { + lastMediaTime = videoElement.currentTime; + lastMediaAdvanceAt = now; + } + const tracks = + videoElement.srcObject instanceof MediaStream + ? videoElement.srcObject.getVideoTracks() + : []; + const mediaStopped = + tracks.length > 0 && + tracks.every((t) => t.muted || t.readyState !== "live"); + const framesStalled = now - lastFrameAt > FRAME_STALL_MS; + const mediaStalled = now - lastMediaAdvanceAt > FRAME_STALL_MS; + + if (framesStalled && mediaStalled && mediaStopped) { + setVisible(false); + } + }, 500); + + frameMonitorCleanups.set(videoElement, () => { + aborted = true; + clearInterval(stallTimer); + if (frameHandle != null) { + const cvfc = ( + videoElement as unknown as { cancelVideoFrameCallback?: CVFC } + ).cancelVideoFrameCallback; + if (typeof cvfc === "function") cvfc.call(videoElement, frameHandle); + } + 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-modal.tsx b/src/components/production-line/video-options-modal.tsx new file mode 100644 index 00000000..82ce0a5d --- /dev/null +++ b/src/components/production-line/video-options-modal.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.ts b/src/hooks/use-video-device-labels.ts new file mode 100644 index 00000000..b017f003 --- /dev/null +++ b/src/hooks/use-video-device-labels.ts @@ -0,0 +1,57 @@ +import { Dispatch, useEffect } from "react"; +import { TGlobalStateAction } from "../global-state/global-state-actions"; +import { noop, uniqBy } from "../helpers"; + +let warmedUpInSession = false; + +type TUseVideoDeviceLabels = { + enabled: boolean; + dispatch: Dispatch; +}; + +export const useVideoDeviceLabels = ({ + enabled, + dispatch, +}: TUseVideoDeviceLabels) => { + useEffect(() => { + if (!enabled || warmedUpInSession) return noop; + + let cancelled = false; + warmedUpInSession = true; + + navigator.mediaDevices + .getUserMedia({ video: true, audio: false }) + .then(async (stream) => { + stream.getTracks().forEach((t) => t.stop()); + if (cancelled) return; + + const devices = await navigator.mediaDevices.enumerateDevices(); + if (cancelled) return; + + dispatch({ + type: "DEVICES_UPDATED", + payload: { + input: uniqBy( + devices.filter((d) => d.kind === "audioinput"), + (d) => d.deviceId + ), + output: uniqBy( + devices.filter((d) => d.kind === "audiooutput"), + (d) => d.deviceId + ), + videoInput: uniqBy( + devices.filter((d) => d.kind === "videoinput"), + (d) => d.deviceId + ), + }, + }); + }) + .catch(() => { + warmedUpInSession = false; + }); + + return () => { + cancelled = true; + }; + }, [enabled, dispatch]); +};