fix: remote display now uses per-instance WebSocket (was shared singleton — broke multi-session)
This commit is contained in:
parent
b7d6c950cf
commit
ef1b8dfa4e
@ -1,18 +1,48 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
|
||||||
EventEmitter,
|
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
signal,
|
signal,
|
||||||
computed,
|
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
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({
|
@Component({
|
||||||
selector: 'app-remote-display',
|
selector: 'app-remote-display',
|
||||||
@ -24,8 +54,6 @@ import { WebSocketService } from '../../services/websocket.service';
|
|||||||
export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
@Input({ required: true }) sessionId!: string;
|
@Input({ required: true }) sessionId!: string;
|
||||||
|
|
||||||
@Output() statusChange = new EventEmitter<'connecting' | 'connected' | 'disconnected' | 'error'>();
|
|
||||||
|
|
||||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
@ViewChild('displayContainer') containerRef!: ElementRef<HTMLDivElement>;
|
@ViewChild('displayContainer') containerRef!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
@ -49,73 +77,145 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
/** Session status from server. */
|
/** Session status from server. */
|
||||||
readonly sessionStatus = signal<string>('waiting');
|
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 {
|
ngOnInit(): void {
|
||||||
// Connect to the session via WebSocket.
|
this.connectWebSocket();
|
||||||
this.ws.connect(this.sessionId);
|
this.setupSubscriptions();
|
||||||
|
|
||||||
// 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 {
|
ngAfterViewInit(): void {
|
||||||
this.canvas = this.canvasRef.nativeElement;
|
this.canvas = this.canvasRef.nativeElement;
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
this.resizeCanvas();
|
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 {
|
ngOnDestroy(): void {
|
||||||
this.ws.disconnect();
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
this.disconnectWebSocket();
|
||||||
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
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 {
|
private resizeCanvas(): void {
|
||||||
@ -125,15 +225,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
this.canvas.height = rect.height;
|
this.canvas.height = rect.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderLoop = (): void => {
|
|
||||||
this.animFrameId = requestAnimationFrame(this.renderLoop);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Mouse events (HUD forwarding) ───────────────────────────────────────
|
// ── Mouse events (HUD forwarding) ───────────────────────────────────────
|
||||||
|
|
||||||
onMouseDown(event: MouseEvent): void {
|
onMouseDown(event: MouseEvent): void {
|
||||||
const pos = this.getCanvasPos(event);
|
const pos = this.getCanvasPos(event);
|
||||||
this.ws.sendHudCommand('mouse_down', {
|
this.sendHudCommand('mouse_down', {
|
||||||
button: event.button,
|
button: event.button,
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
y: pos.y,
|
||||||
@ -143,7 +239,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
|
|
||||||
onMouseUp(event: MouseEvent): void {
|
onMouseUp(event: MouseEvent): void {
|
||||||
const pos = this.getCanvasPos(event);
|
const pos = this.getCanvasPos(event);
|
||||||
this.ws.sendHudCommand('mouse_up', {
|
this.sendHudCommand('mouse_up', {
|
||||||
button: event.button,
|
button: event.button,
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
y: pos.y,
|
y: pos.y,
|
||||||
@ -152,11 +248,11 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
|
|
||||||
onMouseMove(event: MouseEvent): void {
|
onMouseMove(event: MouseEvent): void {
|
||||||
const pos = this.getCanvasPos(event);
|
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 {
|
onWheel(event: WheelEvent): void {
|
||||||
this.ws.sendHudCommand('scroll', {
|
this.sendHudCommand('scroll', {
|
||||||
deltaX: event.deltaX,
|
deltaX: event.deltaX,
|
||||||
deltaY: event.deltaY,
|
deltaY: event.deltaY,
|
||||||
});
|
});
|
||||||
@ -167,7 +263,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown(event: KeyboardEvent): void {
|
onKeyDown(event: KeyboardEvent): void {
|
||||||
this.ws.sendHudCommand('key_down', {
|
this.sendHudCommand('key_down', {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
code: event.code,
|
code: event.code,
|
||||||
ctrl: event.ctrlKey,
|
ctrl: event.ctrlKey,
|
||||||
@ -182,7 +278,7 @@ export class RemoteDisplayComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
}
|
}
|
||||||
|
|
||||||
onKeyUp(event: KeyboardEvent): void {
|
onKeyUp(event: KeyboardEvent): void {
|
||||||
this.ws.sendHudCommand('key_up', {
|
this.sendHudCommand('key_up', {
|
||||||
key: event.key,
|
key: event.key,
|
||||||
code: event.code,
|
code: event.code,
|
||||||
ctrl: event.ctrlKey,
|
ctrl: event.ctrlKey,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user