fix: remote display now uses per-instance WebSocket (was shared singleton — broke multi-session)

This commit is contained in:
Butterfly Dev 2026-04-07 04:01:35 +00:00
parent b7d6c950cf
commit ef1b8dfa4e

View File

@ -1,18 +1,48 @@
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';
import { Subject, filter, takeUntil } from 'rxjs';
// ── Types matching the Rust WsMessage enum ─────────────────────────────────
interface FrameBroadcast {
msg_type: 'frame_broadcast';
data: string;
content_type: string;
}
interface AudioBroadcast {
msg_type: 'audio_broadcast';
data: string;
content_type: string;
}
interface SessionUpdate {
msg_type: 'session_update';
session_id: string;
status: 'waiting' | 'active' | 'disconnected';
resolution: string | null;
}
interface WsError {
msg_type: 'error';
message: string;
}
interface WsAck {
msg_type: 'ack';
message: string;
}
type ServerMessage = FrameBroadcast | AudioBroadcast | SessionUpdate | WsError | WsAck;
@Component({
selector: 'app-remote-display',
@ -24,8 +54,6 @@ import { WebSocketService } from '../../services/websocket.service';
export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
@Input({ required: true }) sessionId!: string;
@Output() statusChange = new EventEmitter<'connecting' | 'connected' | 'disconnected' | 'error'>();
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('displayContainer') containerRef!: ElementRef<HTMLDivElement>;
@ -49,73 +77,145 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
/** Session status from server. */
readonly sessionStatus = signal<string>('waiting');
constructor(private ws: WebSocketService) {}
/** Per-instance WebSocket connection (not the shared singleton). */
private socket: WebSocket | null = null;
private messagesSubject = new Subject<ServerMessage>();
private destroy$ = new Subject<void>();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private resizeObserver: ResizeObserver | null = null;
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');
}
});
this.connectWebSocket();
this.setupSubscriptions();
}
ngAfterViewInit(): void {
this.canvas = this.canvasRef.nativeElement;
this.ctx = this.canvas.getContext('2d');
this.resizeCanvas();
// Start render loop.
this.renderLoop();
// Watch for container size changes (window resize, maximize, etc.).
this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
this.resizeObserver.observe(this.containerRef.nativeElement);
}
ngOnDestroy(): void {
this.ws.disconnect();
this.destroy$.next();
this.destroy$.complete();
this.disconnectWebSocket();
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
this.resizeObserver?.disconnect();
}
// ── WebSocket connection (per-instance) ─────────────────────────────────
private connectWebSocket(): void {
const wsUrl =
window.location.origin.replace(/^http/, 'ws') +
`/ws/${this.sessionId}?client_type=viewer`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log('[RemoteDisplay] connected to session', this.sessionId);
this.status.set('connecting');
this.heartbeatTimer = setInterval(() => this.sendRaw({ msg_type: 'heartbeat' }), 15000);
};
this.socket.onmessage = (event) => {
try {
const msg: ServerMessage = JSON.parse(event.data);
this.messagesSubject.next(msg);
} catch (e) {
console.warn('[RemoteDisplay] failed to parse message', event.data, e);
}
};
this.socket.onclose = (event) => {
console.log('[RemoteDisplay] disconnected', event.code, event.reason);
this.status.set('disconnected');
this.stopHeartbeat();
};
this.socket.onerror = () => {
this.status.set('error');
};
}
private disconnectWebSocket(): void {
this.stopHeartbeat();
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
private setupSubscriptions(): void {
// Display frames
this.messagesSubject.pipe(
filter((m): m is FrameBroadcast => m.msg_type === 'frame_broadcast'),
takeUntil(this.destroy$),
).subscribe((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;
this.frameCount++;
const now = performance.now();
if (now - this.lastFpsTime >= 1000) {
this.fps.set(this.frameCount);
this.frameCount = 0;
this.lastFpsTime = now;
}
});
// Session state updates
this.messagesSubject.pipe(
filter((m): m is SessionUpdate => m.msg_type === 'session_update'),
takeUntil(this.destroy$),
).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');
} else if (update.status === 'disconnected') {
this.status.set('disconnected');
}
}
});
// Error messages
this.messagesSubject.pipe(
filter((m): m is WsError => m.msg_type === 'error'),
takeUntil(this.destroy$),
).subscribe((err) => {
console.warn('[RemoteDisplay] server error:', err.message);
});
}
private sendRaw(msg: Record<string, unknown>): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(msg));
}
}
private sendHudCommand(command: string, params: Record<string, unknown> = {}): void {
this.sendRaw({
msg_type: 'hud_command',
session_id: this.sessionId,
command,
params,
});
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private resizeCanvas(): void {
@ -125,15 +225,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
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', {
this.sendHudCommand('mouse_down', {
button: event.button,
x: pos.x,
y: pos.y,
@ -143,7 +239,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
onMouseUp(event: MouseEvent): void {
const pos = this.getCanvasPos(event);
this.ws.sendHudCommand('mouse_up', {
this.sendHudCommand('mouse_up', {
button: event.button,
x: pos.x,
y: pos.y,
@ -152,11 +248,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
onMouseMove(event: MouseEvent): void {
const pos = this.getCanvasPos(event);
this.ws.sendHudCommand('mouse_move', { x: pos.x, y: pos.y });
this.sendHudCommand('mouse_move', { x: pos.x, y: pos.y });
}
onWheel(event: WheelEvent): void {
this.ws.sendHudCommand('scroll', {
this.sendHudCommand('scroll', {
deltaX: event.deltaX,
deltaY: event.deltaY,
});
@ -167,7 +263,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
}
onKeyDown(event: KeyboardEvent): void {
this.ws.sendHudCommand('key_down', {
this.sendHudCommand('key_down', {
key: event.key,
code: event.code,
ctrl: event.ctrlKey,
@ -182,7 +278,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
}
onKeyUp(event: KeyboardEvent): void {
this.ws.sendHudCommand('key_up', {
this.sendHudCommand('key_up', {
key: event.key,
code: event.code,
ctrl: event.ctrlKey,