diff --git a/ui/src/components/CompositorCanvas.tsx b/ui/src/components/CompositorCanvas.tsx index e846d2fd..fc37a002 100644 --- a/ui/src/components/CompositorCanvas.tsx +++ b/ui/src/components/CompositorCanvas.tsx @@ -87,8 +87,8 @@ const TextContent = styled.div` z-index: 1; `; -/** Inline text editing input shown on double-click */ -const InlineTextInput = styled.input` +/** Inline text editing textarea shown on double-click (supports multiline) */ +const InlineTextInput = styled.textarea` position: absolute; inset: 0; width: 100%; @@ -102,6 +102,13 @@ const InlineTextInput = styled.input` outline: none; z-index: 3; box-sizing: border-box; + resize: none; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.2; + padding: 4px; + font-family: inherit; `; /** Icon badge for image overlay layers */ @@ -271,116 +278,146 @@ const TextOverlayLayer: React.FC<{ isSelected: boolean; scale: number; onPointerDown: (layerId: string, e: React.PointerEvent) => void; + onResizeStart: (layerId: string, handle: ResizeHandle, e: React.PointerEvent) => void; onTextEdit?: (id: string, updates: Partial>) => void; layerRef: (el: HTMLDivElement | null) => void; -}> = React.memo(({ overlay, index, isSelected, scale, onPointerDown, onTextEdit, layerRef }) => { - const [editing, setEditing] = useState(false); - const [editText, setEditText] = useState(overlay.text); - const inputRef = useRef(null); - const cancelledRef = useRef(false); - const committedRef = useRef(false); +}> = React.memo( + ({ overlay, index, isSelected, scale, onPointerDown, onResizeStart, onTextEdit, layerRef }) => { + const [editing, setEditing] = useState(false); + const [editText, setEditText] = useState(overlay.text); + const inputRef = useRef(null); + const cancelledRef = useRef(false); + const committedRef = useRef(false); + + // Issue #1 fix: when the layer is deselected while editing, commit the edit. + const prevSelectedRef = useRef(isSelected); + useEffect(() => { + if (prevSelectedRef.current && !isSelected && editing) { + // Layer was deselected while editing – commit + if (!cancelledRef.current && !committedRef.current) { + committedRef.current = true; + if (editText.trim() && editText !== overlay.text && onTextEdit) { + onTextEdit(overlay.id, { text: editText.trim() }); + } + } + setEditing(false); + } + prevSelectedRef.current = isSelected; + }, [isSelected, editing, editText, overlay.id, overlay.text, onTextEdit]); - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (editing) return; // don't start drag while editing - onPointerDown(overlay.id, e); - }, - [overlay.id, onPointerDown, editing] - ); + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (editing) return; // don't start drag while editing + onPointerDown(overlay.id, e); + }, + [overlay.id, onPointerDown, editing] + ); - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (!onTextEdit) return; - setEditText(overlay.text); - cancelledRef.current = false; - committedRef.current = false; - setEditing(true); - // Focus the input after React renders it - requestAnimationFrame(() => inputRef.current?.focus()); - }, - [onTextEdit, overlay.text] - ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!onTextEdit) return; + setEditText(overlay.text); + cancelledRef.current = false; + committedRef.current = false; + setEditing(true); + // Focus the textarea after React renders it + requestAnimationFrame(() => inputRef.current?.focus()); + }, + [onTextEdit, overlay.text] + ); - const commitEdit = useCallback(() => { - if (cancelledRef.current) return; - if (committedRef.current) return; // guard against double-fire (Enter + blur) - committedRef.current = true; - setEditing(false); - if (editText.trim() && editText !== overlay.text && onTextEdit) { - onTextEdit(overlay.id, { text: editText.trim() }); - } - }, [editText, overlay.id, overlay.text, onTextEdit]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - e.stopPropagation(); - if (e.key === 'Enter') commitEdit(); - if (e.key === 'Escape') { - cancelledRef.current = true; - setEditing(false); + const commitEdit = useCallback(() => { + if (cancelledRef.current) return; + if (committedRef.current) return; // guard against double-fire + committedRef.current = true; + setEditing(false); + if (editText.trim() && editText !== overlay.text && onTextEdit) { + onTextEdit(overlay.id, { text: editText.trim() }); } - }, - [commitEdit] - ); + }, [editText, overlay.id, overlay.text, onTextEdit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + // Ctrl/Cmd+Enter commits; plain Enter inserts newline (textarea default) + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + commitEdit(); + } + if (e.key === 'Escape') { + cancelledRef.current = true; + setEditing(false); + } + }, + [commitEdit] + ); - const hue = layerHue(index + 100); // offset from video layers - const borderColor = isSelected ? 'var(--sk-primary)' : `hsla(${hue}, 70%, 65%, 0.8)`; - const bgColor = isSelected ? `hsla(${hue}, 60%, 50%, 0.25)` : `hsla(${hue}, 60%, 50%, 0.12)`; + const hue = layerHue(index + 100); // offset from video layers + const borderColor = isSelected ? 'var(--sk-primary)' : `hsla(${hue}, 70%, 65%, 0.8)`; + const bgColor = isSelected ? `hsla(${hue}, 60%, 50%, 0.25)` : `hsla(${hue}, 60%, 50%, 0.12)`; - const [r, g, b, a] = overlay.color; - const textColor = `rgba(${r}, ${g}, ${b}, ${(a ?? 255) / 255})`; + const [r, g, b, a] = overlay.color; + const textColor = `rgba(${r}, ${g}, ${b}, ${(a ?? 255) / 255})`; - return ( - - text_{index} - {editing ? ( - setEditText(e.target.value)} - onBlur={commitEdit} - onKeyDown={handleKeyDown} - style={{ fontSize: Math.max(10, overlay.fontSize * scale * 0.6) }} - /> - ) : ( - - - {overlay.text} - - - )} - - ); -}); + return ( + + text_{index} + {editing ? ( + setEditText(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + style={{ fontSize: Math.max(10, overlay.fontSize * scale * 0.6) }} + /> + ) : ( + + + {overlay.text} + + + )} + {isSelected && } + + ); + } +); TextOverlayLayer.displayName = 'TextOverlayLayer'; // ── Image overlay layer ───────────────────────────────────────────────────── @@ -486,7 +523,12 @@ export const CompositorCanvas: React.FC = React.memo( return () => observer.disconnect(); }, [canvasWidth]); + // Issue #1 fix: blur any active element (e.g. inline text input) before + // deselecting so that the input's onBlur → commitEdit fires reliably. const handlePaneClick = useCallback(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } onSelectLayer(null); }, [onSelectLayer]); @@ -546,6 +588,7 @@ export const CompositorCanvas: React.FC = React.memo( isSelected={selectedLayerId === overlay.id} scale={scale} onPointerDown={disabled ? noopPointerDown : onLayerPointerDown} + onResizeStart={disabled ? noopResizeStart : onResizePointerDown} onTextEdit={disabled ? undefined : onTextEdit} layerRef={setLayerRef(overlay.id)} /> diff --git a/ui/src/components/OutputPreviewPanel.tsx b/ui/src/components/OutputPreviewPanel.tsx index c7af087f..4d99d719 100644 --- a/ui/src/components/OutputPreviewPanel.tsx +++ b/ui/src/components/OutputPreviewPanel.tsx @@ -60,6 +60,28 @@ const ResizeEdgeTop = styled.div` z-index: 25; `; +/** Issue #2: Invisible resize handle on the right edge */ +const ResizeEdgeRight = styled.div` + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: ew-resize; + z-index: 25; +`; + +/** Issue #2: Invisible resize handle on the bottom edge */ +const ResizeEdgeBottom = styled.div` + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 6px; + cursor: ns-resize; + z-index: 25; +`; + const DragHeader = styled.div` display: flex; align-items: center; @@ -135,6 +157,11 @@ const PanelBody = styled.div` overflow: hidden; padding: 6px; background: #0a0a0f; + cursor: grab; + + &:active { + cursor: grabbing; + } `; const EmptyMessage = styled.div` @@ -189,7 +216,7 @@ const OutputPreviewPanel: React.FC = React.memo( startX: number; startY: number; origWidth: number; - edge: 'left' | 'top'; + edge: 'left' | 'top' | 'right' | 'bottom'; } | null>(null); const { status, watchStatus, videoRenderer, activeSessionId } = useStreamStore( @@ -243,8 +270,9 @@ const OutputPreviewPanel: React.FC = React.memo( ); // ── Resize handling ───────────────────────────────────────────────────── + // Issue #2 fix: support resizing from all four edges const handleResizeStart = useCallback( - (edge: 'left' | 'top', e: React.PointerEvent) => { + (edge: 'left' | 'top' | 'right' | 'bottom', e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); resizeRef.current = { @@ -256,18 +284,32 @@ const OutputPreviewPanel: React.FC = React.memo( const handleResizeMove = (ev: PointerEvent) => { if (!resizeRef.current) return; - if (resizeRef.current.edge === 'left') { + const curEdge = resizeRef.current.edge; + if (curEdge === 'left') { // Dragging left edge: moving left increases width (panel anchored to right) const dx = resizeRef.current.startX - ev.clientX; setPanelWidth( Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dx)) ); - } else { + } else if (curEdge === 'right') { + // Dragging right edge: moving right increases width + // Panel is anchored to right, so also shift position + const dx = ev.clientX - resizeRef.current.startX; + setPanelWidth( + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dx)) + ); + } else if (curEdge === 'top') { // Dragging top edge: moving up increases height → increase width proportionally const dy = resizeRef.current.startY - ev.clientY; setPanelWidth( Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dy * 1.78)) ); + } else if (curEdge === 'bottom') { + // Dragging bottom edge: moving down increases height → increase width proportionally + const dy = ev.clientY - resizeRef.current.startY; + setPanelWidth( + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.origWidth + dy * 1.78)) + ); } }; @@ -390,6 +432,14 @@ const OutputPreviewPanel: React.FC = React.memo( className="nodrag nopan" onPointerDown={(e) => handleResizeStart('top', e)} /> + handleResizeStart('right', e)} + /> + handleResizeStart('bottom', e)} + /> )} @@ -411,7 +461,9 @@ const OutputPreviewPanel: React.FC = React.memo( {!collapsed && ( - {renderBody()} + + {renderBody()} + )} ); diff --git a/ui/src/hooks/useCompositorLayers.ts b/ui/src/hooks/useCompositorLayers.ts index f5870e03..ff035f8b 100644 --- a/ui/src/hooks/useCompositorLayers.ts +++ b/ui/src/hooks/useCompositorLayers.ts @@ -40,6 +40,7 @@ export interface TextOverlayState { color: [number, number, number, number]; fontSize: number; opacity: number; + rotationDegrees: number; /** Client-side visibility toggle (hidden overlays send opacity=0 to backend) */ visible: boolean; } @@ -123,6 +124,7 @@ interface TextOverlayConfig { color?: [number, number, number, number]; font_size?: number; opacity?: number; + rotation_degrees?: number; } interface ImageOverlayConfig { @@ -169,6 +171,7 @@ function parseTextOverlays(params: Record): TextOverlayState[] color: o.color ?? [255, 255, 255, 255], fontSize: o.font_size ?? 24, opacity: o.opacity ?? 1.0, + rotationDegrees: o.rotation_degrees ?? 0, visible: true, })); } @@ -202,6 +205,7 @@ function serializeTextOverlays(overlays: TextOverlayState[]): TextOverlayConfig[ color: o.color, font_size: o.fontSize, opacity: o.visible ? Math.round(o.opacity * 100) / 100 : 0, + rotation_degrees: Math.round(o.rotationDegrees * 10) / 10, })); } @@ -292,7 +296,7 @@ export const useCompositorLayers = ( // Guard against sync-from-params overwriting a local overlay mutation. // After any local overlay commit we set this to Date.now(). The sync - // effect skips overlay parsing while the guard is active (< 1.5 s). + // effect skips overlay parsing while the guard is active (< 3 s). const overlayCommitGuardRef = useRef(0); // Refs for zero-render drag/resize @@ -334,16 +338,36 @@ export const useCompositorLayers = ( } : p; }); - setLayers(merged); + + // Issue #4 fix: only update layers state if the merged result actually + // differs from current state. This prevents unnecessary re-renders that + // cause positions to briefly revert when switching selected layers. + const layersChanged = + merged.length !== current.length || + merged.some( + (m, i) => + m.id !== current[i].id || + m.x !== current[i].x || + m.y !== current[i].y || + m.width !== current[i].width || + m.height !== current[i].height || + m.opacity !== current[i].opacity || + m.zIndex !== current[i].zIndex || + m.rotationDegrees !== current[i].rotationDegrees || + m.visible !== current[i].visible + ); + if (layersChanged) { + setLayers(merged); + } // Skip overlay re-parse if we just committed a local overlay change. // This prevents stale params from overwriting the local removal/add. const sinceCommit = Date.now() - overlayCommitGuardRef.current; - if (sinceCommit < 1500) return; + if (sinceCommit < 3000) return; setTextOverlays((currentText) => { const parsed = parseTextOverlays(params); - return parsed.map((p) => { + const merged = parsed.map((p) => { const existing = currentText.find((o) => o.id === p.id); if (existing) { return { @@ -354,10 +378,27 @@ export const useCompositorLayers = ( } return p; }); + // Skip update if nothing changed + const changed = + merged.length !== currentText.length || + merged.some( + (m, i) => + m.id !== currentText[i].id || + m.x !== currentText[i].x || + m.y !== currentText[i].y || + m.width !== currentText[i].width || + m.height !== currentText[i].height || + m.opacity !== currentText[i].opacity || + m.rotationDegrees !== currentText[i].rotationDegrees || + m.text !== currentText[i].text || + m.fontSize !== currentText[i].fontSize || + m.visible !== currentText[i].visible + ); + return changed ? merged : currentText; }); setImageOverlays((currentImg) => { const parsed = parseImageOverlays(params); - return parsed.map((p) => { + const merged = parsed.map((p) => { const existing = currentImg.find((o) => o.id === p.id); if (existing) { return { @@ -368,6 +409,20 @@ export const useCompositorLayers = ( } return p; }); + // Skip update if nothing changed + const changed = + merged.length !== currentImg.length || + merged.some( + (m, i) => + m.id !== currentImg[i].id || + m.x !== currentImg[i].x || + m.y !== currentImg[i].y || + m.width !== currentImg[i].width || + m.height !== currentImg[i].height || + m.opacity !== currentImg[i].opacity || + m.visible !== currentImg[i].visible + ); + return changed ? merged : currentImg; }); }, [params, canvasWidth, canvasHeight]); @@ -388,7 +443,7 @@ export const useCompositorLayers = ( height: textOverlay.height, opacity: textOverlay.opacity, zIndex: 0, - rotationDegrees: 0, + rotationDegrees: textOverlay.rotationDegrees, visible: textOverlay.visible, }, kind: 'text', @@ -450,12 +505,32 @@ export const useCompositorLayers = ( ); }, [nodeId, onConfigChange, onParamChange, params, throttleMs]); - // Cleanup throttle on unmount + // Throttled overlay commit for continuous updates (sliders, drag, etc.) + // Prevents flooding the server with config changes on every slider tick. + const throttledOverlayCommit = useMemo(() => { + if (!onConfigChange && !onParamChange) return null; + return throttle( + (nextText: TextOverlayState[], nextImg: ImageOverlayState[]) => { + if (onConfigChange) { + const config = buildConfig(params, layersRef.current, nextText, nextImg); + onConfigChange(nodeId, config); + } else if (onParamChange) { + onParamChange(nodeId, 'text_overlays', serializeTextOverlays(nextText)); + onParamChange(nodeId, 'image_overlays', serializeImageOverlays(nextImg)); + } + }, + throttleMs, + { leading: true, trailing: true } + ); + }, [nodeId, onConfigChange, onParamChange, params, throttleMs]); + + // Cleanup throttles on unmount useEffect( () => () => { throttledConfigChange?.cancel(); + throttledOverlayCommit?.cancel(); }, - [throttledConfigChange] + [throttledConfigChange, throttledOverlayCommit] ); /** Compute updated layer from current pointer position */ @@ -465,12 +540,24 @@ export const useCompositorLayers = ( clientX: number, clientY: number ): LayerState => { - const dx = (clientX - state.startX) / state.scale; - const dy = (clientY - state.startY) / state.scale; + const rawDx = (clientX - state.startX) / state.scale; + const rawDy = (clientY - state.startY) / state.scale; const orig = state.origLayer; if (state.type === 'drag') { - return { ...orig, x: orig.x + dx, y: orig.y + dy }; + return { ...orig, x: orig.x + rawDx, y: orig.y + rawDy }; + } + + // Issue #5 fix: transform mouse delta into the layer's local coordinate + // system so resize handles behave naturally on rotated layers. + let dx = rawDx; + let dy = rawDy; + if (orig.rotationDegrees !== 0) { + const rad = (-orig.rotationDegrees * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + dx = rawDx * cos - rawDy * sin; + dy = rawDx * sin + rawDy * cos; } // Resize @@ -779,6 +866,7 @@ export const useCompositorLayers = ( color: [255, 255, 255, 255], fontSize: 24, opacity: 1.0, + rotationDegrees: 0, visible: true, }, ]; @@ -791,13 +879,20 @@ export const useCompositorLayers = ( const updateTextOverlay = useCallback( (id: string, updates: Partial>) => { + // Arm the guard immediately so sync effect won't overwrite + overlayCommitGuardRef.current = Date.now(); setTextOverlays((prev) => { const next = prev.map((o) => (o.id === id ? { ...o, ...updates } : o)); - commitOverlays(next, imageOverlaysRef.current); + // Use throttled commit to avoid flooding the server on slider drags + if (throttledOverlayCommit) { + throttledOverlayCommit(next, imageOverlaysRef.current); + } else { + commitOverlaysRef.current(next, imageOverlaysRef.current); + } return next; }); }, - [commitOverlays] + [throttledOverlayCommit] ); const removeTextOverlay = useCallback( @@ -840,13 +935,20 @@ export const useCompositorLayers = ( const updateImageOverlay = useCallback( (id: string, updates: Partial>) => { + // Arm the guard immediately so sync effect won't overwrite + overlayCommitGuardRef.current = Date.now(); setImageOverlays((prev) => { const next = prev.map((o) => (o.id === id ? { ...o, ...updates } : o)); - commitOverlays(textOverlaysRef.current, next); + // Use throttled commit to avoid flooding the server on slider drags + if (throttledOverlayCommit) { + throttledOverlayCommit(textOverlaysRef.current, next); + } else { + commitOverlaysRef.current(textOverlaysRef.current, next); + } return next; }); }, - [commitOverlays] + [throttledOverlayCommit] ); const removeImageOverlay = useCallback( diff --git a/ui/src/nodes/CompositorNode.tsx b/ui/src/nodes/CompositorNode.tsx index 92214964..2ca57a1e 100644 --- a/ui/src/nodes/CompositorNode.tsx +++ b/ui/src/nodes/CompositorNode.tsx @@ -351,7 +351,7 @@ const OverlayEditRow = styled.div` font-size: 10px; `; -const OverlayTextInput = styled.input` +const OverlayTextInput = styled.textarea` flex: 1; padding: 2px 4px; font-size: 11px; @@ -362,6 +362,13 @@ const OverlayTextInput = styled.input` outline: none; min-width: 0; pointer-events: auto; + resize: vertical; + min-height: 22px; + max-height: 80px; + font-family: inherit; + line-height: 1.3; + white-space: pre-wrap; + word-break: break-word; &:focus { border-color: var(--sk-primary); @@ -847,10 +854,13 @@ const UnifiedLayerList: React.FC<{ ))} + {/* Issue #6: visual separator between layer list and per-layer controls */} {/* Controls for the selected video layer */} {selectedLayer && ( <> - + {selectedLayer.id} ({Math.round(selectedLayer.x)}, {Math.round(selectedLayer.y)}) @@ -934,9 +944,12 @@ const UnifiedLayerList: React.FC<{ )} {/* Controls for the selected text overlay */} + {/* Issue #6: visual separator + Issue #7: opacity/rotation sliders for text */} {selectedTextOverlay && ( <> - + Text ({Math.round(selectedTextOverlay.x)}, {Math.round(selectedTextOverlay.y)}) @@ -964,24 +977,47 @@ const UnifiedLayerList: React.FC<{ disabled={disabled} className="nodrag nopan" /> - Opacity - + + {/* Issue #7: opacity slider for text layers (same as video layers) */} + + Opacity + { - const v = Number.parseFloat(e.target.value); - if (!Number.isNaN(v)) - onUpdateText(selectedTextOverlay.id, { - opacity: Math.max(0, Math.min(1, v)), - }); - }} + onChange={(e) => + onUpdateText(selectedTextOverlay.id, { + opacity: Number.parseFloat(e.target.value), + }) + } disabled={disabled} className="nodrag nopan" /> - + {(selectedTextOverlay.opacity * 100).toFixed(0)}% + + + {/* Issue #7: rotation slider for text layers (same as video layers) */} + + Rotation + + onUpdateText(selectedTextOverlay.id, { + rotationDegrees: Number.parseFloat(e.target.value), + }) + } + disabled={disabled} + className="nodrag nopan" + /> + {selectedTextOverlay.rotationDegrees.toFixed(0)}° + )} diff --git a/ui/src/views/MonitorView.tsx b/ui/src/views/MonitorView.tsx index aba60cff..2836a75f 100644 --- a/ui/src/views/MonitorView.tsx +++ b/ui/src/views/MonitorView.tsx @@ -78,6 +78,7 @@ import { ESTIMATED_HEIGHT_BY_KIND, } from '@/utils/layoutConstants'; import { viewsLogger } from '@/utils/logger'; +import { updateUrlPath } from '@/utils/moqPeerSettings'; import { validatePipeline } from '@/utils/pipelineValidation'; import { nodeTypes, defaultEdgeOptions } from '@/utils/reactFlowDefaults'; import { collectNodeHeights } from '@/utils/reactFlowInstance'; @@ -1806,8 +1807,12 @@ const MonitorViewContent: React.FC = () => { const previewSetEnablePublish = useStreamStore((s) => s.setEnablePublish); const previewSetEnableWatch = useStreamStore((s) => s.setEnableWatch); const previewConfigLoaded = useStreamStore((s) => s.configLoaded); + const previewSetServerUrl = useStreamStore((s) => s.setServerUrl); + const previewSetOutputBroadcast = useStreamStore((s) => s.setOutputBroadcast); const isPreviewConnected = previewStatus === 'connected'; + // Issue #3 fix: extract MoQ peer settings from the selected session's pipeline + // so preview connects to the correct gateway path and output broadcast. const handleStartPreview = useCallback(async () => { // Configure for watch-only mode (no publish/mic) previewSetEnablePublish(false); @@ -1815,6 +1820,24 @@ const MonitorViewContent: React.FC = () => { if (!previewConfigLoaded) { await previewLoadConfig(); } + + // Extract gateway_path and output_broadcast from the pipeline's moq_peer node + const moqNode = pipeline + ? Object.values(pipeline.nodes).find((n) => n.kind === 'transport::moq::peer' && n.params) + : undefined; + if (moqNode?.params) { + const params = moqNode.params as Record; + const gatewayPath = params.gateway_path as string | undefined; + const outputBroadcast = params.output_broadcast as string | undefined; + const currentUrl = useStreamStore.getState().serverUrl; + if (gatewayPath && currentUrl) { + previewSetServerUrl(updateUrlPath(currentUrl, gatewayPath)); + } + if (outputBroadcast) { + previewSetOutputBroadcast(outputBroadcast); + } + } + await previewConnect(); }, [ previewSetEnablePublish, @@ -1822,6 +1845,9 @@ const MonitorViewContent: React.FC = () => { previewConfigLoaded, previewLoadConfig, previewConnect, + pipeline, + previewSetServerUrl, + previewSetOutputBroadcast, ]); // Handle entering staging mode