From 1ecf6c81771e15762fc696b1a85ae5e7a7772370 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:39:45 +0000 Subject: [PATCH 1/6] Fix WebRTC data channels and implement advanced mouse input reliability features including server-cu --- opennow-stable/src/main/index.ts | 21 +- opennow-stable/src/preload/index.ts | 2 + opennow-stable/src/renderer/src/App.tsx | 14 + .../src/renderer/src/gfn/webrtcClient.ts | 249 ++++++++++++++++-- opennow-stable/src/shared/gfn.ts | 4 + opennow-stable/src/shared/ipc.ts | 2 + 6 files changed, 276 insertions(+), 16 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 8219783..082fd39 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, systemPreferences, session } from "electron"; +import { app, BrowserWindow, ipcMain, dialog, systemPreferences, session, powerSaveBlocker } from "electron"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; @@ -157,6 +157,7 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; +let powerSaveBlockerId: number | null = null; function emitToRenderer(event: MainToRendererSignalingEvent): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -183,6 +184,8 @@ async function createMainWindow(): Promise { contextIsolation: true, nodeIntegration: false, sandbox: false, + backgroundThrottling: false, + v8CacheOptions: "code", }, }); @@ -441,6 +444,22 @@ function registerIpcHandlers(): void { return signalingClient.sendIceCandidate(payload); }); + // Power save blocker handlers - prevent display sleep during streaming + ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_START, async (): Promise => { + if (powerSaveBlockerId === null) { + powerSaveBlockerId = powerSaveBlocker.start("prevent-display-sleep"); + console.log("[Main] Power save blocker started, id:", powerSaveBlockerId); + } + }); + + ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP, async (): Promise => { + if (powerSaveBlockerId !== null) { + powerSaveBlocker.stop(powerSaveBlockerId); + console.log("[Main] Power save blocker stopped, id:", powerSaveBlockerId); + powerSaveBlockerId = null; + } + }); + // Toggle fullscreen via IPC (for completeness) ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => { if (mainWindow && !mainWindow.isDestroyed()) { diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 638397e..0914fec 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -76,6 +76,8 @@ const api: PreloadApi = { resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), pingRegions: (regions: StreamRegion[]) => ipcRenderer.invoke(IPC_CHANNELS.PING_REGIONS, regions), + startPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_START), + stopPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP), }; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index c9388f1..e8a3916 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -810,6 +810,10 @@ export function App(): JSX.Element { }); setLaunchError(null); setStreamStatus("streaming"); + // Start power save blocker to prevent display sleep during streaming + void window.openNow.startPowerSaveBlocker().catch((err) => { + console.warn("[App] Failed to start power save blocker:", err); + }); } } else if (event.type === "remote-ice") { await clientRef.current?.addRemoteCandidate(event.candidate); @@ -818,6 +822,8 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); + // Stop power save blocker on unexpected disconnect + void window.openNow.stopPowerSaveBlocker().catch(() => {}); launchInFlightRef.current = false; } else if (event.type === "error") { console.error("Signaling error:", event.message); @@ -1258,9 +1264,15 @@ export function App(): JSX.Element { clientRef.current = null; setNavbarActiveSession(null); resetLaunchRuntime(); + // Stop power save blocker when streaming ends + void window.openNow.stopPowerSaveBlocker().catch((err) => { + console.warn("[App] Failed to stop power save blocker:", err); + }); void refreshNavbarActiveSession(); } catch (error) { console.error("Stop failed:", error); + // Ensure power save blocker is stopped even on error + void window.openNow.stopPowerSaveBlocker().catch(() => {}); } }, [authSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt]); @@ -1269,6 +1281,8 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); + // Stop power save blocker when dismissing launch error + void window.openNow.stopPowerSaveBlocker().catch(() => {}); void refreshNavbarActiveSession(); }, [refreshNavbarActiveSession, resetLaunchRuntime]); diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index d822bc8..852f024 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -431,9 +431,19 @@ export class GfnWebRtcClient { private pc: RTCPeerConnection | null = null; private reliableInputChannel: RTCDataChannel | null = null; private mouseInputChannel: RTCDataChannel | null = null; + private cursorChannel: RTCDataChannel | null = null; + private statsChannel: RTCDataChannel | null = null; private controlChannel: RTCDataChannel | null = null; private audioContext: AudioContext | null = null; + // Server timebase offset for timestamp synchronization (matches official client's time remapping) + private serverTimebaseOffsetMs = 0; + private serverTimebaseSynced = false; + + // Mouse sub-pixel accumulation for fractional deltas (matches official client's Up/Hp fields) + private mouseSubPixelX = 0; + private mouseSubPixelY = 0; + private inputReady = false; private inputProtocolVersion = 2; private heartbeatTimer: number | null = null; @@ -513,6 +523,11 @@ export class GfnWebRtcClient { private inputQueuePressureLoggedAtMs = 0; private inputQueueDropCount = 0; + // Mouse event queue for coalescing and rate limiting (matches official client's ql/qc) + private mouseEventQueue: Array<{ dx: number; dy: number; ts: bigint; dropped?: boolean }> = []; + private static readonly MOUSE_QUEUE_MAX_SIZE = 8; // Max pending mouse events before coalescing + private mouseQueueHighWatermark = 0; + // Microphone private micManager: MicrophoneManager | null = null; private micState: MicState = "uninitialized"; @@ -718,10 +733,17 @@ export class GfnWebRtcClient { } this.reliableInputChannel?.close(); this.mouseInputChannel?.close(); + this.cursorChannel?.close(); + this.statsChannel?.close(); this.controlChannel?.close(); this.reliableInputChannel = null; this.mouseInputChannel = null; + this.cursorChannel = null; + this.statsChannel = null; this.controlChannel = null; + this.serverTimebaseSynced = false; + this.mouseSubPixelX = 0; + this.mouseSubPixelY = 0; } private clearTimers(): void { @@ -1031,6 +1053,10 @@ export class GfnWebRtcClient { this.pendingMouseDx = 0; this.pendingMouseDy = 0; this.pendingMouseTimestampUs = null; + this.mouseSubPixelX = 0; + this.mouseSubPixelY = 0; + this.mouseEventQueue = []; + this.mouseQueueHighWatermark = 0; this.mouseDeltaFilter.reset(); this.mouseFlushLastTickMs = 0; this.inputQueuePeakBufferedBytesWindow = 0; @@ -1406,6 +1432,39 @@ export class GfnWebRtcClient { this.mouseInputChannel.onopen = () => { this.log(`Mouse channel open (partially reliable, maxPacketLifeTime=${this.partialReliableThresholdMs}ms)`); }; + + // Server-rendered cursor channel for precise cursor positioning + // CRITICAL: Must use reliable=true, ordered=true for accurate cursor positioning + this.cursorChannel = pc.createDataChannel("cursor_channel", { + ordered: true, + reliable: true, + }); + + this.cursorChannel.onopen = () => { + this.log("Cursor channel open (server-rendered cursor, reliable=true)"); + }; + + this.cursorChannel.onmessage = (event) => { + // Handle server cursor position updates (for server-rendered cursor) + this.handleCursorMessage(event.data as ArrayBuffer); + }; + + // Stats/telemetry channel for stream quality metrics + // Fire-and-forget: no ordering, no reliability, no retransmits (matches official) + this.statsChannel = pc.createDataChannel("stats_channel", { + ordered: false, + reliable: false, + maxRetransmits: 0, + }); + + this.statsChannel.onopen = () => { + this.log("Stats channel open (telemetry, fire-and-forget)"); + }; + + this.statsChannel.onmessage = (event) => { + // Handle server telemetry/stats messages + this.handleServerStatsMessage(event.data as ArrayBuffer); + }; } private mapTimerNotificationCode(rawCode: number): StreamTimeWarning["code"] | null { @@ -1422,6 +1481,106 @@ export class GfnWebRtcClient { return null; } + /** + * Handle server cursor position updates from cursor_channel. + * The server sends absolute cursor position for server-rendered cursor mode. + */ + private handleCursorMessage(data: ArrayBuffer): void { + try { + const view = new DataView(data); + // Cursor messages are typically simple position updates + // Format varies by protocol version, but usually contains x, y coordinates + if (data.byteLength >= 4) { + const x = view.getInt16(0, false); // BE + const y = view.getInt16(2, false); // BE + this.log(`Server cursor position: x=${x}, y=${y}`); + // In server-rendered cursor mode, the cursor is drawn by the server + // We may need to hide the local cursor or sync positions + } + } catch (e) { + this.log(`Cursor message parse error: ${String(e)}`); + } + } + + /** + * Handle server stats/telemetry messages from stats_channel. + * The server sends performance metrics for adaptive streaming. + * + * Message format (from vendor_beautified.js:18668-18682): + * - Byte 0: version/type + * - If version >= 4: + * - Bytes 1-8: timestamp (float64 BE) / 1e6 = seconds + * - Bytes 9-16: server timestamp (float64 BE) + * - Bytes 17-24: unknown (float64 BE) + * - Bytes 25-28: overall score (float32 BE) + * - Bytes 29-32: gpuPerfScore (float32 BE) + * - Bytes 33-36: serverPerfScore (float32 BE) + * - etc. + */ + private handleServerStatsMessage(data: ArrayBuffer): void { + try { + const view = new DataView(data); + if (data.byteLength < 1) return; + + const version = view.getUint8(0); + + // Version 3: simple ACK message + if (version === 3) { + this.log("Server stats: ACK received"); + return; + } + + // Version >= 4: full performance metrics (matches vendor:18668-18682) + if (version >= 4 && data.byteLength >= 70) { + // Extract timestamps for timebase synchronization + const serverTimestampUs = view.getFloat64(1, true); // microseconds + const serverPerfTimestamp = view.getFloat64(9, true); + + // Calculate server timebase offset for timestamp remapping + if (!this.serverTimebaseSynced) { + const localNowUs = performance.now() * 1000; + this.serverTimebaseOffsetMs = (serverTimestampUs - localNowUs) / 1000; + this.serverTimebaseSynced = true; + this.log(`Server timebase synced: offset=${this.serverTimebaseOffsetMs.toFixed(2)}ms`); + } + + // Extract performance scores (all float32 BE) + const overall = view.getFloat32(25, true); + const gpuPerfScore = view.getFloat32(29, true); + const serverPerfScore = view.getFloat32(33, true); + const visualScore = view.getFloat32(37, true); + const decoderScore = view.getFloat32(41, true); + const downlinkLag = view.getFloat32(45, true); + const downlinkCongestion = view.getFloat32(49, true); + const uplink = view.getFloat32(53, true); + + this.log( + `Server stats: overall=${overall.toFixed(2)}, ` + + `gpu=${gpuPerfScore.toFixed(2)}, server=${serverPerfScore.toFixed(2)}, ` + + `visual=${visualScore.toFixed(2)}, decoder=${decoderScore.toFixed(2)}, ` + + `lag=${downlinkLag.toFixed(2)}, congestion=${downlinkCongestion.toFixed(2)}, ` + + `uplink=${uplink.toFixed(2)}` + ); + + // Could use these metrics for adaptive bitrate or quality adjustments + } + } catch (e) { + this.log(`Server stats message parse error: ${String(e)}`); + } + } + + /** + * Convert local timestamp to server timebase for input events. + * This ensures timestamps are synchronized with the server's clock. + */ + private remapTimestampToServer(localTimestampMs: number): bigint { + if (!this.serverTimebaseSynced) { + return timestampUs(localTimestampMs); + } + const remappedMs = localTimestampMs + this.serverTimebaseOffsetMs; + return BigInt(Math.floor(remappedMs * 1000)); + } + private async onControlChannelMessage(data: string | Blob | ArrayBuffer): Promise { let payloadText: string; if (typeof data === "string") { @@ -1813,13 +1972,13 @@ export class GfnWebRtcClient { } if (this.activeInputMode === "gamepad") { - this.pendingMouseDx = 0; - this.pendingMouseDy = 0; - this.pendingMouseTimestampUs = null; + this.mouseEventQueue = []; + this.mouseSubPixelX = 0; + this.mouseSubPixelY = 0; return; } - if (this.pendingMouseDx === 0 && this.pendingMouseDy === 0) { + if (this.mouseEventQueue.length === 0) { return; } @@ -1840,15 +1999,39 @@ export class GfnWebRtcClient { return; } + // Process queued mouse events (matches official client's batching behavior) + // Sum all queued deltas and use the oldest timestamp for the batch + let totalDx = 0; + let totalDy = 0; + let batchTimestamp: bigint | null = null; + let coalescedCount = 0; + + for (const event of this.mouseEventQueue) { + totalDx += event.dx; + totalDy += event.dy; + if (event.dropped) coalescedCount++; + if (batchTimestamp === null) { + batchTimestamp = event.ts; + } + } + + // Add sub-pixel remainders + totalDx += Math.trunc(this.mouseSubPixelX); + totalDy += Math.trunc(this.mouseSubPixelY); + + // Store fractional remainders for next frame + this.mouseSubPixelX = this.mouseSubPixelX - Math.trunc(this.mouseSubPixelX); + this.mouseSubPixelY = this.mouseSubPixelY - Math.trunc(this.mouseSubPixelY); + + // Clear queue + this.mouseEventQueue = []; + const payload = this.inputEncoder.encodeMouseMove({ - dx: Math.max(-32768, Math.min(32767, this.pendingMouseDx)), - dy: Math.max(-32768, Math.min(32767, this.pendingMouseDy)), - timestampUs: this.pendingMouseTimestampUs ?? timestampUs(), + dx: Math.max(-32768, Math.min(32767, totalDx)), + dy: Math.max(-32768, Math.min(32767, totalDy)), + timestampUs: batchTimestamp ?? timestampUs(), }); - this.pendingMouseDx = 0; - this.pendingMouseDy = 0; - this.pendingMouseTimestampUs = null; this.sendReliable(payload); }; @@ -1867,10 +2050,45 @@ export class GfnWebRtcClient { return; } - // Apply user-configured mouse sensitivity multiplier before queuing - this.pendingMouseDx += Math.round(this.mouseDeltaFilter.getX() * this.mouseSensitivity); - this.pendingMouseDy += Math.round(this.mouseDeltaFilter.getY() * this.mouseSensitivity); - this.pendingMouseTimestampUs = timestampUs(eventTimestampMs); + // Apply user-configured mouse sensitivity multiplier + const scaledX = this.mouseDeltaFilter.getX() * this.mouseSensitivity; + const scaledY = this.mouseDeltaFilter.getY() * this.mouseSensitivity; + + // Accumulate fractional deltas in sub-pixel storage (matches official Up/Hp) + // This preserves sub-pixel precision across multiple events + const roundedX = Math.round(scaledX); + const roundedY = Math.round(scaledY); + + // Store fractional remainder for next accumulation + this.mouseSubPixelX += scaledX - roundedX; + this.mouseSubPixelY += scaledY - roundedY; + + // Remap timestamp to server timebase for synchronization + const timestampUs = this.remapTimestampToServer(eventTimestampMs); + + // Queue depth-based rate limiting (matches official client's ql/qc behavior) + // When queue is full, coalesce with last event rather than dropping + if (this.mouseEventQueue.length >= GfnWebRtcClient.MOUSE_QUEUE_MAX_SIZE) { + // Queue full - coalesce with last pending event (sum deltas, use newer timestamp) + const lastEvent = this.mouseEventQueue[this.mouseEventQueue.length - 1]; + lastEvent.dx += roundedX; + lastEvent.dy += roundedY; + lastEvent.ts = timestampUs; + lastEvent.dropped = true; // Mark as coalesced + + // Track high watermark for diagnostics + if (this.mouseEventQueue.length > this.mouseQueueHighWatermark) { + this.mouseQueueHighWatermark = this.mouseEventQueue.length; + } + return; + } + + // Add to queue + this.mouseEventQueue.push({ + dx: roundedX, + dy: roundedY, + ts: timestampUs, + }); }; const onPointerMove = (event: PointerEvent) => { @@ -2483,8 +2701,9 @@ export class GfnWebRtcClient { const rtcConfig: RTCConfiguration = { iceServers: toRtcIceServers(session.iceServers), - bundlePolicy: "max-bundle", + bundlePolicy: "balanced", rtcpMuxPolicy: "require", + iceCandidatePoolSize: 4, }; const pc = new RTCPeerConnection(rtcConfig); diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 71eed96..c53644f 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -352,4 +352,8 @@ export interface OpenNowApi { exportLogs(format?: "text" | "json"): Promise; /** Ping all regions and return latency results */ pingRegions(regions: StreamRegion[]): Promise; + /** Start power save blocker to prevent display sleep during streaming */ + startPowerSaveBlocker(): Promise; + /** Stop power save blocker when streaming ends */ + stopPowerSaveBlocker(): Promise; } diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index dd79bc2..f89dfc8 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -28,6 +28,8 @@ export const IPC_CHANNELS = { SETTINGS_RESET: "settings:reset", LOGS_EXPORT: "logs:export", LOGS_GET_RENDERER: "logs:get-renderer", + POWER_SAVE_BLOCKER_START: "power-save-blocker:start", + POWER_SAVE_BLOCKER_STOP: "power-save-blocker:stop", } as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; From f4e1119d47193158c831d95cb8e515862bfe34e2 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:42:53 +0000 Subject: [PATCH 2/6] fix(css): Show default Windows cursor in stream view Co-authored-by: Capy --- opennow-stable/src/renderer/src/styles.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index c291424..0462b70 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2210,6 +2210,13 @@ button.game-card-store-chip.active:hover { object-fit: contain; display: block; position: relative; z-index: 1; outline: none; + cursor: default; /* Show default cursor - user wants to see Windows cursor */ +} + +/* Hide cursor only when pointer lock is active (user is controlling the game) */ +.sv-video:active, +.sv-video:focus:active { + cursor: none; } .sv-video:focus, .sv-video:focus-visible { From 2bbbde750edf8894ba9a557620e83e7645fcc614 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:51:40 +0000 Subject: [PATCH 3/6] feat(cursor): Add server-rendered cursor overlay canvas system - Add cursor overlay canvas to StreamView component - Implement cursor rendering in video styles.css with mix-blend-mode - Add cursor state to StreamDiagnostics (cursorVisible, cursorX, cursorY, cursorType) - Parse cursor visibility/type from server cursor_channel messages - Hide Windows cursor when server cursor is visible - Support multiple cursor types: arrow, ibeam, hand, crosshair, wait Fixes black cursor issue by rendering cursor as overlay on top of video Co-authored-by: Capy --- opennow-stable/src/renderer/src/App.tsx | 6 + .../renderer/src/components/StreamView.tsx | 149 ++++++++++++++++++ .../src/renderer/src/gfn/webrtcClient.ts | 82 +++++++++- opennow-stable/src/renderer/src/styles.css | 8 +- 4 files changed, 233 insertions(+), 12 deletions(-) diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index e8a3916..28a8852 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -154,6 +154,10 @@ function defaultDiagnostics(): StreamDiagnostics { serverRegion: "", micState: "uninitialized", micEnabled: false, + cursorVisible: false, + cursorX: 0, + cursorY: 0, + cursorType: 'arrow', }; } @@ -371,6 +375,7 @@ export function App(): JSX.Element { // Refs const videoRef = useRef(null); const audioRef = useRef(null); + const cursorRef = useRef(null); const clientRef = useRef(null); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); @@ -1517,6 +1522,7 @@ export function App(): JSX.Element { ; audioRef: React.Ref; + cursorRef: React.Ref; stats: StreamDiagnostics; showStats: boolean; shortcuts: { @@ -99,6 +100,7 @@ function formatWarningSeconds(value: number | undefined): string | null { export function StreamView({ videoRef, audioRef, + cursorRef, stats, showStats, shortcuts, @@ -214,6 +216,7 @@ export function StreamView({ // Local ref for video element to manage focus const localVideoRef = useRef(null); + const localCursorRef = useRef(null); // Combined ref callback that sets both local and forwarded ref const setVideoRef = useCallback((element: HTMLVideoElement | null) => { @@ -226,6 +229,133 @@ export function StreamView({ } }, [videoRef]); + // Cursor canvas ref callback + const setCursorRef = useCallback((element: HTMLCanvasElement | null) => { + localCursorRef.current = element; + if (typeof cursorRef === "function") { + cursorRef(element); + } else if (cursorRef && "current" in cursorRef) { + (cursorRef as React.MutableRefObject).current = element; + } + }, [cursorRef]); + + // Cursor rendering effect + useEffect(() => { + if (!localCursorRef.current || !stats.cursorVisible) return; + + const canvas = localCursorRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Get cursor position (server coordinates scaled to viewport) + const videoEl = localVideoRef.current; + if (!videoEl) return; + + const videoRect = videoEl.getBoundingClientRect(); + const videoWidth = videoEl.videoWidth || videoRect.width; + const videoHeight = videoEl.videoHeight || videoRect.height; + + // Scale server cursor position to video element size + const scaleX = videoRect.width / videoWidth; + const scaleY = videoRect.height / videoHeight; + + const cursorX = stats.cursorX * scaleX; + const cursorY = stats.cursorY * scaleY; + + // Draw cursor based on type + ctx.save(); + + switch (stats.cursorType) { + case 'arrow': + // Standard arrow cursor + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cursorX, cursorY); + ctx.lineTo(cursorX, cursorY + 18); + ctx.lineTo(cursorX + 4, cursorY + 14); + ctx.lineTo(cursorX + 10, cursorY + 20); + ctx.lineTo(cursorX + 12, cursorY + 18); + ctx.lineTo(cursorX + 6, cursorY + 12); + ctx.lineTo(cursorX + 12, cursorY + 12); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + break; + case 'ibeam': + // I-beam text cursor + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + const iwidth = 2; + const iheight = 18; + ctx.fillRect(cursorX - iwidth/2, cursorY - iheight/2, iwidth, iheight); + ctx.strokeRect(cursorX - iwidth/2 - 0.5, cursorY - iheight/2 - 0.5, iwidth + 1, iheight + 1); + break; + case 'hand': + // Hand pointer + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cursorX, cursorY, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cursorX, cursorY, 3, 0, Math.PI * 2); + ctx.fillStyle = '#000000'; + ctx.fill(); + break; + case 'crosshair': + // Crosshair + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + const csize = 10; + ctx.beginPath(); + ctx.moveTo(cursorX - csize, cursorY); + ctx.lineTo(cursorX + csize, cursorY); + ctx.moveTo(cursorX, cursorY - csize); + ctx.lineTo(cursorX, cursorY + csize); + ctx.stroke(); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.stroke(); + break; + case 'wait': + // Hourglass/wait cursor + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + const w = 10; + const h = 16; + ctx.beginPath(); + ctx.moveTo(cursorX - w/2, cursorY - h/2); + ctx.lineTo(cursorX + w/2, cursorY - h/2); + ctx.lineTo(cursorX, cursorY); + ctx.lineTo(cursorX + w/2, cursorY + h/2); + ctx.lineTo(cursorX - w/2, cursorY + h/2); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + break; + default: + // Default dot cursor + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cursorX, cursorY, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + + ctx.restore(); + }, [stats.cursorVisible, stats.cursorX, stats.cursorY, stats.cursorType]); + // Focus video element when stream is ready (not connecting anymore) useEffect(() => { if (!isConnecting && localVideoRef.current && hasResolution) { @@ -250,6 +380,7 @@ export function StreamView({ muted tabIndex={0} className="sv-video" + style={{ cursor: stats.cursorVisible ? 'none' : 'default' }} onClick={() => { // Ensure video has focus when clicked for pointer lock to work if (localVideoRef.current && document.activeElement !== localVideoRef.current) { @@ -257,6 +388,24 @@ export function StreamView({ } }} /> + {/* Cursor overlay canvas */} +