From ffdea4fe62abaaad5998f221bf1a77d55d75da82 Mon Sep 17 00:00:00 2001 From: Laurent Dubertrand Date: Wed, 11 Mar 2026 08:05:54 +0100 Subject: [PATCH] video call --- client/src/app/chat-page.component.html | 79 ++--- client/src/app/chat-page.component.scss | 57 +++ client/src/app/chat-page.component.ts | 126 ++++--- client/src/app/chat-session.service.ts | 324 ++++++++---------- client/src/app/models.ts | 7 +- client/src/app/peer-call-modal.component.scss | 155 +++++++++ client/src/app/peer-call-modal.component.ts | 168 +++++++++ .../src/app/peer-video-modal.component.scss | 54 --- client/src/app/peer-video-modal.component.ts | 86 ----- 9 files changed, 644 insertions(+), 412 deletions(-) create mode 100644 client/src/app/peer-call-modal.component.scss create mode 100644 client/src/app/peer-call-modal.component.ts delete mode 100644 client/src/app/peer-video-modal.component.scss delete mode 100644 client/src/app/peer-video-modal.component.ts diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 22fcb28..509f34c 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -1,37 +1,39 @@
- + - @if (incomingVoiceCallPeer(); as callingPeer) { -
-
-
-
-

Incoming voice call

-

{{ callingPeer.displayName }} is calling you.

-
-
-
- - +
+
+
@@ -272,9 +274,9 @@ class="composer-call" type="button" [disabled]="!canStartSelectedVoiceCall()" - (click)="startVoiceCall(selectedPeer.id)" - title="Start voice call" - aria-label="Start voice call" + (click)="openCallChoice(selectedPeer.id)" + title="Start call" + aria-label="Start call" > 📞 @@ -284,24 +286,13 @@ class="composer-hangup" type="button" (click)="endVoiceCall(selectedPeer.id)" - title="End voice call" - aria-label="End voice call" + title="End call" + aria-label="End call" > 🛑 } - - + + +
+
+
{{ callMode === 'audio' ? 'Peer audio' : 'Peer' }}
+ @if (callMode === 'video' && remoteStream) { + + } @else { +
+ {{ + callMode === 'audio' + ? 'Audio-only call in progress.' + : callState === 'incoming' + ? 'Waiting for you to join.' + : 'Waiting for peer video…' + }} +
+ } + +
+
You
+ @if (callMode === 'video' && localStream) { + + } @else { +
+ {{ callMode === 'audio' ? 'Audio only' : callState === 'incoming' ? 'Camera starts when you accept.' : 'Starting your camera…' }} +
+ } +
+
+
+ +
+ @if (callState === 'incoming') { + + + } @else { + + } +
+
+
+ } + `, + styleUrl: './peer-call-modal.component.scss', +}) +export class PeerCallModalComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input() visible = false; + @Input() peerName = 'Peer'; + @Input() callState: 'incoming' | 'outgoing' | 'active' = 'active'; + @Input() callMode: CallMode = 'video'; + @Input() statusText = ''; + @Input() localStream: MediaStream | null = null; + @Input() remoteStream: MediaStream | null = null; + @Output() readonly acceptRequested = new EventEmitter(); + @Output() readonly rejectRequested = new EventEmitter(); + @Output() readonly hangupRequested = new EventEmitter(); + + @ViewChild('localVideoElement') + set localVideoElementRef(value: ElementRef | undefined) { + this.localVideoElement = value; + this.syncVideoSources(); + } + + @ViewChild('remoteVideoElement') + set remoteVideoElementRef(value: ElementRef | undefined) { + this.remoteVideoElement = value; + this.syncVideoSources(); + } + + private localVideoElement?: ElementRef; + private remoteVideoElement?: ElementRef; + + ngAfterViewInit(): void { + this.syncVideoSources(); + } + + ngOnChanges(): void { + this.syncVideoSources(); + } + + ngOnDestroy(): void { + this.detachVideo(this.localVideoElement?.nativeElement); + this.detachVideo(this.remoteVideoElement?.nativeElement); + } + + requestDismiss(): void { + if (this.callState === 'incoming') { + this.rejectRequested.emit(); + return; + } + + this.hangupRequested.emit(); + } + + private syncVideoSources(): void { + this.syncVideo(this.localVideoElement?.nativeElement, this.visible ? this.localStream : null, true); + this.syncVideo(this.remoteVideoElement?.nativeElement, this.visible ? this.remoteStream : null, true); + } + + private syncVideo(video: HTMLVideoElement | undefined, stream: MediaStream | null, muted: boolean): void { + if (!video) { + return; + } + + video.muted = muted; + video.srcObject = stream; + + if (stream) { + void video.play().catch(() => { + // Autoplay can be delayed until the next user gesture on some platforms. + }); + return; + } + + video.pause(); + } + + private detachVideo(video: HTMLVideoElement | undefined): void { + if (!video) { + return; + } + + video.pause(); + video.srcObject = null; + } +} diff --git a/client/src/app/peer-video-modal.component.scss b/client/src/app/peer-video-modal.component.scss deleted file mode 100644 index 1f649ab..0000000 --- a/client/src/app/peer-video-modal.component.scss +++ /dev/null @@ -1,54 +0,0 @@ -:host { - display: contents; -} - -.video-modal-backdrop { - position: fixed; - inset: 0; - z-index: 1200; - display: grid; - place-items: center; - padding: 1.5rem; - background: rgba(3, 8, 14, 0.72); - backdrop-filter: blur(10px); -} - -.video-modal-card { - width: min(100%, 56rem); - border: 1px solid var(--surface-border); - border-radius: 1.5rem; - background: var(--panel-background); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); -} - -.video-modal-header { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: start; - padding: 1rem 1rem 0; -} - -.video-modal-close { - width: 2.5rem; - height: 2.5rem; - border: 0; - border-radius: 999px; - color: var(--page-text); - background: var(--badge-background); - font-size: 1.35rem; - line-height: 1; -} - -.video-modal-body { - padding: 1rem; -} - -.video-modal-player { - width: 100%; - display: block; - border-radius: 1rem; - background: #000; - aspect-ratio: 16 / 9; - object-fit: cover; -} diff --git a/client/src/app/peer-video-modal.component.ts b/client/src/app/peer-video-modal.component.ts deleted file mode 100644 index bd62876..0000000 --- a/client/src/app/peer-video-modal.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core'; - -@Component({ - selector: 'app-peer-video-modal', - imports: [CommonModule], - template: ` - @if (visible) { -
-
-
-
-

{{ title }}

-

Live webcam capture from your peer.

-
- -
- -
- -
-
-
- } - `, - styleUrl: './peer-video-modal.component.scss', -}) -export class PeerVideoModalComponent implements AfterViewInit, OnChanges, OnDestroy { - @Input() visible = false; - @Input() stream: MediaStream | null = null; - @Input() title = 'Live webcam'; - @Output() readonly closeRequested = new EventEmitter(); - @ViewChild('videoElement') - set videoElementRef(value: ElementRef | undefined) { - this.videoElement = value; - this.syncVideoSource(); - } - - private videoElement?: ElementRef; - - ngAfterViewInit(): void { - this.syncVideoSource(); - } - - ngOnChanges(): void { - this.syncVideoSource(); - } - - ngOnDestroy(): void { - this.detachVideoSource(); - } - - requestClose(): void { - this.closeRequested.emit(); - } - - private syncVideoSource(): void { - const video = this.videoElement?.nativeElement; - - if (!video) { - return; - } - - video.muted = true; - video.srcObject = this.visible ? this.stream : null; - - if (this.visible && this.stream) { - void video.play().catch(() => { - // Autoplay may be delayed until user interaction depending on platform policy. - }); - } - } - - private detachVideoSource(): void { - const video = this.videoElement?.nativeElement; - - if (!video) { - return; - } - - video.pause(); - video.srcObject = null; - } -}