From 6b4c6419ce52e729a426d38bac05ab6d2107dcc4 Mon Sep 17 00:00:00 2001 From: Butterfly Dev Date: Tue, 7 Apr 2026 03:32:23 +0000 Subject: [PATCH] =?UTF-8?q?desktop:=20components/remote-display=20?= =?UTF-8?q?=E2=80=94=20canvas-based=20display=20viewer,=20mouse/keyboard?= =?UTF-8?q?=20HUD=20forwarding,=20FPS=20counter,=20connection=20status=20o?= =?UTF-8?q?verlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote-display.component.html | 50 +++++ .../remote-display.component.scss | 88 ++++++++ .../remote-display.component.ts | 203 ++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 desktop/src/app/components/remote-display/remote-display.component.html create mode 100644 desktop/src/app/components/remote-display/remote-display.component.scss create mode 100644 desktop/src/app/components/remote-display/remote-display.component.ts diff --git a/desktop/src/app/components/remote-display/remote-display.component.html b/desktop/src/app/components/remote-display/remote-display.component.html new file mode 100644 index 0000000..d38c758 --- /dev/null +++ b/desktop/src/app/components/remote-display/remote-display.component.html @@ -0,0 +1,50 @@ +
+ + + + + @if (status() === 'connecting' || status() === 'disconnected' || status() === 'error') { +
+ @if (status() === 'connecting') { +
+
+

Connecting to session…

+
+ } @else if (status() === 'disconnected') { +
+ πŸ”Œ +

Agent disconnected

+

Waiting for VM agent to connect

+
+ } @else { +
+ ⚠️ +

Connection error

+
+ } +
+ } + + + @if (status() === 'connected') { +
+ {{ fps() }} FPS + @if (resolution()) { + {{ resolution() }} + } +
+ } +
diff --git a/desktop/src/app/components/remote-display/remote-display.component.scss b/desktop/src/app/components/remote-display/remote-display.component.scss new file mode 100644 index 0000000..0d8bd4c --- /dev/null +++ b/desktop/src/app/components/remote-display/remote-display.component.scss @@ -0,0 +1,88 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.remote-display { + position: relative; + width: 100%; + height: 100%; + background: #000; + outline: none; +} + +.display-canvas { + width: 100%; + height: 100%; + display: block; +} + +// ── Overlay ─────────────────────────────────────────────────────────────── + +.display-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.75); + z-index: 5; +} + +.overlay-content { + text-align: center; + color: #e0e0e0; + font-size: 14px; +} + +.overlay-icon { + font-size: 48px; + display: block; + margin-bottom: 12px; +} + +.sub { + font-size: 12px; + color: #888; + margin-top: 4px; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top: 3px solid #0078d4; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +// ── HUD info ────────────────────────────────────────────────────────────── + +.hud-info { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 8px; + z-index: 6; +} + +.hud-fps, +.hud-res { + font-size: 11px; + background: rgba(0, 0, 0, 0.6); + color: #0f0; + padding: 2px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; +} + +.hud-res { + color: #0af; +} diff --git a/desktop/src/app/components/remote-display/remote-display.component.ts b/desktop/src/app/components/remote-display/remote-display.component.ts new file mode 100644 index 0000000..61cede4 --- /dev/null +++ b/desktop/src/app/components/remote-display/remote-display.component.ts @@ -0,0 +1,203 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnDestroy, + ViewChild, + ElementRef, + signal, + computed, + AfterViewInit, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { WebSocketService } from '../../services/websocket.service'; + +@Component({ + selector: 'app-remote-display', + standalone: true, + imports: [CommonModule], + templateUrl: './remote-display.component.html', + styleUrl: './remote-display.component.scss', +}) +export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy { + @Input({ required: true }) sessionId!: string; + + @Output() statusChange = new EventEmitter<'connecting' | 'connected' | 'disconnected' | 'error'>(); + + @ViewChild('canvas') canvasRef!: ElementRef; + @ViewChild('displayContainer') containerRef!: ElementRef; + + /** Current connection status. */ + readonly status = signal<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting'); + + /** Display resolution string. */ + readonly resolution = signal(null); + + /** Frames per second counter. */ + private frameCount = 0; + private lastFpsTime = 0; + readonly fps = signal(0); + + private canvas: HTMLCanvasElement | null = null; + private ctx: CanvasRenderingContext2D | null = null; + private img = new Image(); + private animFrameId = 0; + private hasFrame = false; + + /** Session status from server. */ + readonly sessionStatus = signal('waiting'); + + constructor(private ws: WebSocketService) {} + + ngOnInit(): void { + // Connect to the session via WebSocket. + this.ws.connect(this.sessionId); + + // Subscribe to display frames. + this.ws.displayFrames.subscribe({ + next: (frame) => { + if (!this.canvas || !this.ctx) return; + this.img.onload = () => { + this.ctx!.drawImage(this.img, 0, 0, this.canvas!.width, this.canvas!.height); + this.hasFrame = true; + }; + this.img.src = 'data:' + frame.content_type + ';base64,' + frame.data; + // FPS counter. + this.frameCount++; + const now = performance.now(); + if (now - this.lastFpsTime >= 1000) { + this.fps.set(this.frameCount); + this.frameCount = 0; + this.lastFpsTime = now; + } + }, + error: () => { + this.status.set('error'); + this.statusChange.emit('error'); + }, + }); + + // Subscribe to session state updates. + this.ws.sessionUpdates.subscribe((update) => { + if (update.session_id === this.sessionId) { + this.sessionStatus.set(update.status); + this.resolution.set(update.resolution); + if (update.status === 'active') { + this.status.set('connected'); + this.statusChange.emit('connected'); + } else if (update.status === 'disconnected') { + this.status.set('disconnected'); + this.statusChange.emit('disconnected'); + } + } + }); + + // Connection state. + this.ws.connected.subscribe((connected) => { + if (connected) { + this.status.set('connecting'); + } else { + this.status.set('disconnected'); + this.statusChange.emit('disconnected'); + } + }); + } + + ngAfterViewInit(): void { + this.canvas = this.canvasRef.nativeElement; + this.ctx = this.canvas.getContext('2d'); + this.resizeCanvas(); + // Start render loop. + this.renderLoop(); + } + + ngOnDestroy(): void { + this.ws.disconnect(); + if (this.animFrameId) cancelAnimationFrame(this.animFrameId); + } + + private resizeCanvas(): void { + if (!this.canvas || !this.containerRef) return; + const rect = this.containerRef.nativeElement.getBoundingClientRect(); + this.canvas.width = rect.width; + this.canvas.height = rect.height; + } + + private renderLoop = (): void => { + this.animFrameId = requestAnimationFrame(this.renderLoop); + }; + + // ── Mouse events (HUD forwarding) ─────────────────────────────────────── + + onMouseDown(event: MouseEvent): void { + const pos = this.getCanvasPos(event); + this.ws.sendHudCommand('mouse_down', { + button: event.button, + x: pos.x, + y: pos.y, + buttons: event.buttons, + }); + } + + onMouseUp(event: MouseEvent): void { + const pos = this.getCanvasPos(event); + this.ws.sendHudCommand('mouse_up', { + button: event.button, + x: pos.x, + y: pos.y, + }); + } + + onMouseMove(event: MouseEvent): void { + const pos = this.getCanvasPos(event); + this.ws.sendHudCommand('mouse_move', { x: pos.x, y: pos.y }); + } + + onWheel(event: WheelEvent): void { + this.ws.sendHudCommand('scroll', { + deltaX: event.deltaX, + deltaY: event.deltaY, + }); + } + + onContextMenu(event: Event): void { + event.preventDefault(); + } + + onKeyDown(event: KeyboardEvent): void { + this.ws.sendHudCommand('key_down', { + key: event.key, + code: event.code, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + meta: event.metaKey, + }); + // Prevent browser default for most keys when display is active. + if (this.hasFrame && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); + } + } + + onKeyUp(event: KeyboardEvent): void { + this.ws.sendHudCommand('key_up', { + key: event.key, + code: event.code, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + meta: event.metaKey, + }); + } + + private getCanvasPos(event: MouseEvent): { x: number; y: number } { + if (!this.canvas) return { x: 0, y: 0 }; + const rect = this.canvas.getBoundingClientRect(); + return { + x: Math.round(((event.clientX - rect.left) / rect.width) * this.canvas.width), + y: Math.round(((event.clientY - rect.top) / rect.height) * this.canvas.height), + }; + } +}