From e94e56ff6e778937f3f5923cb63217193a413b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Westman?= Date: Tue, 16 Jun 2026 14:42:34 +0200 Subject: [PATCH 1/8] feat: add video support for intercom lines (WHIP/WHEP, H264) Adds in-call participant video: publish the local camera via WHIP and consume remote video via WHEP (H264 over SMB), with self-preview, per-tile video grid, source pinning, return-feed and fullscreen controls, plus the video-enabled toggle in production setup. --- e2e/tests/create-production.spec.ts | 10 +- src/api/api.ts | 58 +- src/assets/icons/delete.svg | 1 + src/assets/icons/icon.tsx | 15 + src/assets/icons/pin.svg | 1 + src/assets/icons/unpin.svg | 1 + src/assets/icons/video_off.svg | 1 + src/assets/icons/video_on.svg | 1 + src/components/calls-page/calls-page.tsx | 65 ++ .../calls-page/video-line-join-card.test.tsx | 134 ++++ .../calls-page/video-line-join-card.tsx | 177 ++++++ src/components/checkbox/checkbox.tsx | 43 +- .../create-production-components.ts | 18 +- .../create-production-page.tsx | 39 ++ .../use-create-production.tsx | 14 +- .../delete-button/delete-button-components.ts | 10 + .../use-fetch-production-list.test.ts | 2 +- .../use-add-production-line.ts | 15 +- .../production-line/call-header.tsx | 12 + .../production-line/fullscreen-controls.tsx | 119 ++++ .../fullscreen-overlay-utils.test.ts | 40 ++ .../fullscreen-overlay-utils.ts | 36 ++ .../production-line/ice-gathering.test.ts | 24 +- .../production-line/ice-gathering.ts | 18 +- .../match-video-tile-labels.test.ts | 457 ++++++++++++++ .../match-video-tile-labels.ts | 95 +++ .../production-line/pin-data-channel.ts | 18 + .../production-line-components.ts | 94 ++- .../production-line/production-line.tsx | 475 ++++++++++++++- .../remote-fullscreen-controls.tsx | 80 +++ .../production-line/return-feed-tile.tsx | 308 ++++++++++ .../production-line/select-devices.tsx | 40 +- .../production-line/self-preview-tile.tsx | 336 ++++++++++ .../symphony-rtc-connection-component.tsx | 10 +- src/components/production-line/types.ts | 6 + .../production-line/use-call-state-sync.ts | 11 + .../production-line/use-line-polling.ts | 28 +- .../use-rtc-connection.test.ts | 68 ++- .../production-line/use-rtc-connection.ts | 180 +++++- .../use-shadow-rtc-connection.ts | 185 ++++++ .../use-update-call-device.tsx | 108 ++-- .../production-line/use-video-elements.ts | 27 + .../production-line/use-video-input.ts | 75 +++ .../production-line/use-video-source-pin.ts | 90 +++ .../production-line/use-video-tile-grid.ts | 262 ++++++++ .../production-line/user-action-slots.tsx | 192 ++++++ .../production-line/user-controls.tsx | 18 + src/components/production-line/user-list.tsx | 65 +- .../production-line/video-element-factory.ts | 572 ++++++++++++++++++ .../production-line/video-options-modal.tsx | 58 ++ src/components/production-list/line-block.tsx | 6 + .../manage-production-buttons.tsx | 22 + .../production-list-components.ts | 14 +- .../production-list-expanded-content.tsx | 4 +- src/components/shared/collapsible-item.tsx | 6 +- src/components/tooltip/tooltip.tsx | 69 ++- .../user-settings-form/use-submit-form.tsx | 1 + .../user-settings-form.test.tsx | 101 +++- .../user-settings-form/user-settings-form.tsx | 37 +- .../verify-decision/confirmation-modal.tsx | 6 +- src/global-state/global-state-reducer.test.ts | 7 +- src/global-state/global-state-reducer.ts | 20 +- src/global-state/types.ts | 3 + src/hooks/use-fetch-devices.ts | 10 +- .../use-initiate-production-call.test.ts | 32 +- src/hooks/use-initiate-production-call.tsx | 34 +- src/hooks/use-video-device-labels.ts | 57 ++ 67 files changed, 4880 insertions(+), 261 deletions(-) create mode 100644 src/assets/icons/delete.svg create mode 100644 src/assets/icons/pin.svg create mode 100644 src/assets/icons/unpin.svg create mode 100644 src/assets/icons/video_off.svg create mode 100644 src/assets/icons/video_on.svg create mode 100644 src/components/calls-page/video-line-join-card.test.tsx create mode 100644 src/components/calls-page/video-line-join-card.tsx create mode 100644 src/components/production-line/fullscreen-controls.tsx create mode 100644 src/components/production-line/fullscreen-overlay-utils.test.ts create mode 100644 src/components/production-line/fullscreen-overlay-utils.ts create mode 100644 src/components/production-line/match-video-tile-labels.test.ts create mode 100644 src/components/production-line/match-video-tile-labels.ts create mode 100644 src/components/production-line/pin-data-channel.ts create mode 100644 src/components/production-line/remote-fullscreen-controls.tsx create mode 100644 src/components/production-line/return-feed-tile.tsx create mode 100644 src/components/production-line/self-preview-tile.tsx create mode 100644 src/components/production-line/use-shadow-rtc-connection.ts create mode 100644 src/components/production-line/use-video-elements.ts create mode 100644 src/components/production-line/use-video-input.ts create mode 100644 src/components/production-line/use-video-source-pin.ts create mode 100644 src/components/production-line/use-video-tile-grid.ts create mode 100644 src/components/production-line/user-action-slots.tsx create mode 100644 src/components/production-line/video-element-factory.ts create mode 100644 src/components/production-line/video-options-modal.tsx create mode 100644 src/hooks/use-video-device-labels.ts diff --git a/e2e/tests/create-production.spec.ts b/e2e/tests/create-production.spec.ts index 03772850..641fbc95 100644 --- a/e2e/tests/create-production.spec.ts +++ b/e2e/tests/create-production.spec.ts @@ -73,8 +73,16 @@ 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 + ); + // ...and both should also offer the per-line video toggle, so the total + // checkbox count is two per line. + 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 85e4bb25..fe612a89 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -25,7 +25,11 @@ export type TPreset = { type TCreateProductionOptions = { name: string; - lines: { name: string; programOutputLine?: boolean }[]; + lines: { + name: string; + programOutputLine?: boolean; + videoEnabled?: boolean; + }[]; }; type TParticipant = { @@ -34,6 +38,8 @@ type TParticipant = { endpointId: string; isActive: boolean; isWhip: boolean; + isWhepReceiver?: boolean; + hasVideo: boolean; }; type TLine = { @@ -42,6 +48,8 @@ type TLine = { smbConferenceId: string; participants: TParticipant[]; programOutputLine?: boolean; + videoEnabled?: boolean; + whepSourceSessionId?: string | null; }; export type TBasicProductionResponse = { @@ -150,6 +158,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, }: { @@ -202,7 +251,8 @@ export const API = { addProductionLine: ( productionId: string, name: string, - programOutputLine?: boolean + programOutputLine?: boolean, + videoEnabled?: boolean ): Promise => handleFetchRequest( fetch(`${API_URL}production/${productionId}/line`, { @@ -214,6 +264,7 @@ export const API = { body: JSON.stringify({ name, programOutputLine, + videoEnabled, }), }) ), @@ -324,6 +375,7 @@ export const API = { body: JSON.stringify(options), }) ), + listPresets: (): Promise<{ presets: TPreset[] }> => handleFetchRequest<{ presets: TPreset[] }>( fetch(`${API_URL}preset`, { @@ -333,6 +385,7 @@ export const API = { }, }) ), + deletePreset: async (id: string): Promise => { const response = await fetch(`${API_URL}preset/${id}`, { method: "DELETE", @@ -344,6 +397,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/icon.tsx b/src/assets/icons/icon.tsx index 26aeeec1..7f499768 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -25,6 +25,11 @@ 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"; export const MicMuted = () => ; @@ -79,3 +84,13 @@ export const SaveIcon = () => ; export const HelpIcon = () => ; export const WarningIcon = () => ; + +export const DeleteIcon = () => ; + +export const VideoOnIcon = () => ; + +export const VideoOffIcon = () => ; + +export const PinIcon = () => ; + +export const UnpinIcon = () => ; 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 fad32c05..272a9e50 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -28,6 +28,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"; @@ -135,6 +136,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, @@ -152,6 +163,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(); @@ -274,6 +286,7 @@ export const CallsPage = () => { ); const program: PendingProgramLine[] = []; + const video: PendingVideoLine[] = []; const validRefs: typeof pendingCallRefs = []; pendingCallRefs.forEach((ref, i) => { @@ -291,6 +304,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, + }); } }); @@ -301,6 +321,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, @@ -326,6 +353,7 @@ export const CallsPage = () => { autoJoinTriggeredRef.current = true; const programKeys = pendingProgramKeysRef.current; + const videoKeys = pendingVideoKeysRef.current; const existingKeys = new Set( Object.values(calls) @@ -338,6 +366,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) => { @@ -549,6 +578,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 && (productionId || addCallPreSelected) && ( ({ + 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 b6a4a36a..32e3577d 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"; @@ -193,6 +194,11 @@ export const CallHeaderComponent = ({ )} + {!open && line?.videoEnabled && ( + + + + )} @@ -262,6 +268,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 9bfa8dba..0ccefaab 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -1,6 +1,7 @@ -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 { API } from "../../api/api.ts"; import { isBrowserFirefox, isMobile, isTablet } from "../../bowser.ts"; import { useGlobalState } from "../../global-state/context-provider.tsx"; import { CallState } from "../../global-state/types.ts"; @@ -25,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"; @@ -44,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; @@ -90,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 [open, setOpen] = useState(!isMobile); const [hotkeysModalOpen, setHotkeysModalOpen] = useState(false); const { @@ -100,6 +130,7 @@ export const ProductionLine = ({ audioLevelAboveThreshold, connectionState, audioElements, + videoElements, sessionId, hotkeys: savedHotkeys, dataChannel, @@ -114,6 +145,22 @@ export const ProductionLine = ({ dispatch, }); + const videoEnabled = joinProductionOptions?.videoEnabled ?? false; + const [inputVideoStream] = useVideoInput({ + videoInputId: videoEnabled + ? (joinProductionOptions?.videoinput ?? "no-device") + : null, + 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); @@ -149,6 +196,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 && @@ -162,6 +272,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, @@ -226,6 +368,13 @@ export const ProductionLine = ({ } }, [audioElements]); + useEffect(() => { + shadowAudioElements.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.volume = value; + }); + }, [shadowAudioElements, value]); + const { startTalking, stopTalking, @@ -345,6 +494,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; @@ -426,6 +585,206 @@ 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 [siblingLineParticipants, setSiblingLineParticipants] = useState< + import("./types.ts").TParticipant[] + >([]); + useEffect(() => { + if (!shadowJoinOptions) { + setSiblingLineParticipants([]); + return undefined; + } + const productionId = parseInt(shadowJoinOptions.productionId, 10); + const lineId = parseInt(shadowJoinOptions.lineId, 10); + const interval = window.setInterval(() => { + API.fetchProductionLine(productionId, lineId) + .then((l: import("./types.ts").TLine) => + setSiblingLineParticipants(l.participants) + ) + .catch(() => {}); + }, 2000); + return () => window.clearInterval(interval); + }, [shadowJoinOptions]); + + const whipParticipantName = useMemo( + () => siblingLineParticipants.find((p) => p.isWhip)?.name ?? null, + [siblingLineParticipants] + ); + + 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); + } + // Keep SMB's native pin (PinnedEndpointsChanged over the data channel) in + // sync with the whitelist pin. A manual re-pin previously updated only the + // manager-side ssrc-whitelist while _pinMap stayed on the PREVIOUSLY pinned + // source; when that earlier source later left the call, SMB tore down the + // now-dangling pin and the receiver's video collapsed (audio, which is not + // pinned, was unaffected). The auto-pin effect already syncs both — manual + // pins must too. + desiredPinEndpointIdRef.current = nextEndpointId; + sendPinnedEndpoint(dataChannel, nextEndpointId); + pendingPinPromiseRef.current = pushVideoSourceToBackend(next); + setUserOptionsTarget(null); + }; + // TODO detect if browser back button is pressed and run exit(); return ( @@ -457,12 +816,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 && ( @@ -554,9 +974,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} /> )} @@ -575,7 +1002,7 @@ export const ProductionLine = ({ )} )} -
+ {confirmModalOpen && ( @@ -595,6 +1022,15 @@ export const ProductionLine = ({ onCancel={() => setConfirmModalOpen(false)} /> )} + {pendingWhepTargetSessionId && whepConfirmTexts && ( + setPendingWhepTargetSessionId(null)} + /> + )} )} @@ -613,6 +1049,29 @@ export const ProductionLine = ({ onSave={() => 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..642ff452 --- /dev/null +++ b/src/components/production-line/return-feed-tile.tsx @@ -0,0 +1,308 @@ +import styled from "@emotion/styled"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { TUseVideoInputValues } from "./use-video-input"; +import { + exitTileFullscreen, + isTileFullscreen, + makePipDraggable, + requestTileFullscreen, +} from "./video-element-factory"; + +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 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; +`; + +const PipContainer = styled.div<{ visible: boolean }>` + display: ${({ visible }) => (visible ? "block" : "none")}; + position: absolute; + bottom: 1rem; + left: 1rem; + width: 22rem; + /* Definite height rather than aspect-ratio: with a