video call

This commit is contained in:
2026-03-11 08:05:54 +01:00
parent f0e2b60f43
commit ffdea4fe62
9 changed files with 644 additions and 412 deletions

View File

@@ -1,37 +1,39 @@
<main class="chat-shell py-4"> <main class="chat-shell py-4">
<div class="container-lg"> <div class="container-lg">
<section class="chat-page panel p-3 p-lg-4"> <section class="chat-page panel p-3 p-lg-4">
<app-peer-video-modal <app-peer-call-modal
[visible]="remoteVideoModalVisible()" [visible]="callModalVisible()"
[stream]="remoteVideoStream()" [peerName]="callModalPeer()?.displayName ?? 'Peer'"
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'" [callState]="callModalState()"
(closeRequested)="closeRemoteVideoModal()" [callMode]="callModalMode()"
></app-peer-video-modal> [statusText]="callModalStatusText()"
[localStream]="localCallStream()"
[remoteStream]="remoteCallVideoStream()"
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
></app-peer-call-modal>
<audio #callAudioElement hidden autoplay playsinline></audio> <audio #callAudioElement hidden autoplay playsinline></audio>
@if (incomingVoiceCallPeer(); as callingPeer) { @if (callChoicePeer(); as selectedCallPeer) {
<div class="call-modal-backdrop"> <div class="call-choice-backdrop" (click)="closeCallChoice()">
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()"> <section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
<div class="mb-3"> <p class="call-choice-eyebrow">Start a call</p>
<div> <h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
<h2 class="h5 mb-1">Incoming voice call</h2> <p class="small mb-3">Choose whether to place a full video call or audio only.</p>
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p> <div class="call-choice-actions">
</div> <button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
</div> <span class="call-choice-icon">📹</span>
<div class="d-flex flex-wrap gap-2 justify-content-end"> <span>Video call</span>
<button
class="btn btn-success"
type="button"
(click)="acceptIncomingVoiceCall(callingPeer.id)"
>
Accept
</button> </button>
<button <button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
class="btn btn-outline-secondary" <span class="call-choice-icon">🎙️</span>
type="button" <span>Audio only</span>
(click)="rejectIncomingVoiceCall(callingPeer.id)" </button>
> </div>
Reject <div class="d-flex justify-content-end mt-3">
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
Cancel
</button> </button>
</div> </div>
</section> </section>
@@ -272,9 +274,9 @@
class="composer-call" class="composer-call"
type="button" type="button"
[disabled]="!canStartSelectedVoiceCall()" [disabled]="!canStartSelectedVoiceCall()"
(click)="startVoiceCall(selectedPeer.id)" (click)="openCallChoice(selectedPeer.id)"
title="Start voice call" title="Start call"
aria-label="Start voice call" aria-label="Start call"
> >
📞 📞
</button> </button>
@@ -284,24 +286,13 @@
class="composer-hangup" class="composer-hangup"
type="button" type="button"
(click)="endVoiceCall(selectedPeer.id)" (click)="endVoiceCall(selectedPeer.id)"
title="End voice call" title="End call"
aria-label="End voice call" aria-label="End call"
> >
🛑 🛑
</button> </button>
} }
<button
class="composer-camera"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
(click)="toggleCameraStream(selectedPeer.id)"
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
>
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
</button>
<button <button
class="composer-voice" class="composer-voice"
type="button" type="button"

View File

@@ -33,6 +33,63 @@
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.call-choice-backdrop {
position: fixed;
inset: 0;
z-index: 1240;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(3, 8, 14, 0.46);
backdrop-filter: blur(6px);
}
.call-choice-card {
width: min(100%, 25rem);
}
.call-choice-eyebrow {
margin-bottom: 0.45rem;
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--page-text-soft);
}
.call-choice-actions {
display: grid;
gap: 0.85rem;
}
.call-choice-button {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
padding: 1rem 1.1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: var(--page-text);
background: var(--surface-background);
text-align: left;
}
.call-choice-button:hover,
.call-choice-button:focus-visible {
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
background: var(--surface-hover-background);
}
.call-choice-icon {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
background: var(--badge-background);
font-size: 1.2rem;
}
.back-link { .back-link {
color: var(--link-color); color: var(--link-color);
text-decoration: none; text-decoration: none;

View File

@@ -4,14 +4,14 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { PeerVideoModalComponent } from './peer-video-modal.component'; import { PeerCallModalComponent } from './peer-call-modal.component';
import { ChatSessionService } from './chat-session.service'; import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component'; import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState, PeerSummary } from './models'; import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({ @Component({
selector: 'app-chat-page', selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent], imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent],
templateUrl: './chat-page.component.html', templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
@@ -46,6 +46,7 @@ export class ChatPageComponent implements OnDestroy {
messageText = ''; messageText = '';
readonly forwardingEntryId = signal<string | null>(null); readonly forwardingEntryId = signal<string | null>(null);
readonly callChoicePeerId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false); readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false); readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false); readonly isDictating = signal(false);
@@ -63,8 +64,19 @@ export class ChatPageComponent implements OnDestroy {
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser()); readonly currentUser = computed(() => this.session.currentUser());
readonly incomingVoiceCallPeer = computed(() => { readonly callModalPeerId = computed(() =>
const peerId = this.session.incomingVoiceCallPeerId(); this.session.activeVoiceCallPeerId()
?? this.session.incomingVoiceCallPeerId()
?? this.session.outgoingVoiceCallPeerId()
?? null,
);
readonly callModalPeer = computed(() => {
const peerId = this.callModalPeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly callChoicePeer = computed(() => {
const peerId = this.callChoicePeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null; return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
}); });
@@ -73,13 +85,50 @@ export class ChatPageComponent implements OnDestroy {
.messages() .messages()
.filter((entry) => entry.peerId === this.peerId()), .filter((entry) => entry.peerId === this.peerId()),
); );
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteCallAudioStream = computed(() => readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''), this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
);
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
); );
readonly callModalMode = computed<CallMode>(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video');
readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? ''));
readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? ''));
readonly callModalVisible = computed(() => !!this.callModalPeer());
readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.callModalPeerId();
if (!peerId) {
return 'active';
}
if (this.session.incomingVoiceCallPeerId() === peerId) {
return 'incoming';
}
if (this.session.outgoingVoiceCallPeerId() === peerId) {
return 'outgoing';
}
return 'active';
});
readonly callModalStatusText = computed(() => {
const peer = this.callModalPeer();
if (!peer) {
return '';
}
switch (this.callModalState()) {
case 'incoming':
return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`;
case 'outgoing':
return this.callModalMode() === 'audio'
? 'Calling… your microphone is ready.'
: 'Calling… your camera and microphone are ready.';
default:
return this.callModalMode() === 'audio'
? 'Connected with live audio.'
: 'Connected with live video and audio.';
}
});
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => { readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.peerId(); const peerId = this.peerId();
@@ -265,6 +314,29 @@ export class ChatPageComponent implements OnDestroy {
}); });
} }
openCallChoice(peerId: string): void {
if (!peerId) {
return;
}
this.callChoicePeerId.set(peerId);
}
closeCallChoice(): void {
this.callChoicePeerId.set(null);
}
async startSelectedCall(mode: CallMode): Promise<void> {
const peerId = this.callChoicePeerId() ?? this.peerId();
if (!peerId) {
return;
}
this.callChoicePeerId.set(null);
await this.session.startVoiceCall(peerId, mode);
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> { async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0); const file = input.files?.item(0);
@@ -474,19 +546,6 @@ export class ChatPageComponent implements OnDestroy {
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
} }
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
return;
}
await this.session.startCameraStream(peerId);
}
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
}
async endVoiceCall(peerId: string): Promise<void> { async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId); await this.session.endVoiceCall(peerId);
} }
@@ -496,10 +555,6 @@ export class ChatPageComponent implements OnDestroy {
return; return;
} }
if (peerId !== this.peerId()) {
await this.router.navigate(['/chat', peerId]);
}
await this.session.acceptVoiceCall(peerId); await this.session.acceptVoiceCall(peerId);
} }
@@ -561,22 +616,6 @@ export class ChatPageComponent implements OnDestroy {
return this.indicatorTone(this.webRtcState()) === 'offline'; return this.indicatorTone(this.webRtcState()) === 'offline';
} }
isStreamingCameraToSelectedPeer(): boolean {
const peerId = this.peerId();
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
}
closeRemoteVideoModal(): void {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.dismissRemoteVideoModal(peerId);
}
async switchPeer(peerId: string): Promise<void> { async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) { if (!peerId || peerId === this.peerId()) {
return; return;
@@ -585,6 +624,7 @@ export class ChatPageComponent implements OnDestroy {
await this.stopDictation(true); await this.stopDictation(true);
this.stopVoiceRecording(true); this.stopVoiceRecording(true);
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
this.callChoicePeerId.set(null);
this.emojiPickerOpen.set(false); this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId); this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]); await this.router.navigate(['/chat', peerId]);

View File

@@ -7,6 +7,7 @@ import {
AdminUserSummary, AdminUserSummary,
AuthenticationOptionsResponse, AuthenticationOptionsResponse,
AuthResponse, AuthResponse,
CallMode,
ChatEntry, ChatEntry,
ConnectionState, ConnectionState,
DataEnvelope, DataEnvelope,
@@ -26,11 +27,9 @@ type PeerBundle = {
pendingCandidates: RTCIceCandidateInit[]; pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean; pendingNegotiation: boolean;
announceConnectionEvents: boolean; announceConnectionEvents: boolean;
localCameraStream?: MediaStream; localCallStream?: MediaStream;
cameraSenders: RTCRtpSender[]; mediaSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream; remoteCameraStream?: MediaStream;
localAudioStream?: MediaStream;
audioSenders: RTCRtpSender[];
remoteAudioStream?: MediaStream; remoteAudioStream?: MediaStream;
}; };
@@ -128,7 +127,6 @@ export class ChatSessionService {
readonly messages = signal<ChatEntry[]>([]); readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]); readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]); readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly incomingVoiceCallPeerId = signal<string | null>(null); readonly incomingVoiceCallPeerId = signal<string | null>(null);
readonly outgoingVoiceCallPeerId = signal<string | null>(null); readonly outgoingVoiceCallPeerId = signal<string | null>(null);
readonly activeVoiceCallPeerId = signal<string | null>(null); readonly activeVoiceCallPeerId = signal<string | null>(null);
@@ -174,10 +172,13 @@ export class ChatSessionService {
string, string,
{ resolve: (text: string) => void; reject: (reason?: unknown) => void } { resolve: (text: string) => void; reject: (reason?: unknown) => void }
>(); >();
private readonly incomingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly outgoingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly activeCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly localCallStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]); private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]); private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(null); private readonly localCallPeerId = signal<string | null>(null);
private readonly activeAudioPeerId = signal<string | null>(null);
private sessionKeepaliveIntervalId: number | null = null; private sessionKeepaliveIntervalId: number | null = null;
private websocketHeartbeatIntervalId: number | null = null; private websocketHeartbeatIntervalId: number | null = null;
private websocketReconnectTimeoutId: number | null = null; private websocketReconnectTimeoutId: number | null = null;
@@ -369,94 +370,15 @@ export class ChatSessionService {
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
async startCameraStream(peerId: string): Promise<void> { localCallStreamForPeer(peerId: string): MediaStream | null {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
this.error.set('This browser does not support webcam capture.');
return;
}
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.error.set('You must be connected to signaling before starting webcam capture.');
return;
}
const activeCameraPeerId = this.activeCameraPeerId();
if (activeCameraPeerId && activeCameraPeerId !== peerId) {
await this.stopCameraStream(activeCameraPeerId);
}
const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localCameraStream) {
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
bundle.localCameraStream = stream;
bundle.cameraSenders = stream.getTracks().map((track) => {
track.onended = () => {
void this.stopCameraStream(peerId, false);
};
return bundle.pc.addTrack(track, stream);
});
this.activeCameraPeerId.set(peerId);
this.sendCameraState(peerId, true);
this.addSystemMessage(peerId, 'Sharing webcam capture.');
await this.negotiatePeer(peerId, bundle);
} catch {
this.error.set('Could not start webcam capture.');
}
} }
async stopCameraStream(peerId: string, notifyPeer = true): Promise<void> { callModeForPeer(peerId: string): CallMode | null {
const bundle = this.peerBundles.get(peerId); return this.activeCallModes().find((entry) => entry.peerId === peerId)?.mode
?? this.incomingCallModes().find((entry) => entry.peerId === peerId)?.mode
if (!bundle?.localCameraStream && this.activeCameraPeerId() !== peerId) { ?? this.outgoingCallModes().find((entry) => entry.peerId === peerId)?.mode
return; ?? null;
}
if (bundle) {
for (const sender of bundle.cameraSenders) {
bundle.pc.removeTrack(sender);
}
bundle.cameraSenders = [];
if (bundle.localCameraStream) {
for (const track of bundle.localCameraStream.getTracks()) {
track.onended = null;
track.stop();
}
}
bundle.localCameraStream = undefined;
}
if (this.activeCameraPeerId() === peerId) {
this.activeCameraPeerId.set(null);
}
if (notifyPeer) {
this.sendCameraState(peerId, false);
}
this.addSystemMessage(peerId, 'Stopped webcam capture.');
if (bundle) {
await this.negotiatePeer(peerId, bundle);
}
}
isStreamingCameraToPeer(peerId: string): boolean {
return this.activeCameraPeerId() === peerId;
} }
remoteVideoStreamForPeer(peerId: string): MediaStream | null { remoteVideoStreamForPeer(peerId: string): MediaStream | null {
@@ -467,29 +389,34 @@ export class ChatSessionService {
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null; return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
} }
dismissRemoteVideoModal(peerId: string): void { async startVoiceCall(peerId: string, mode: CallMode): Promise<void> {
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
async startVoiceCall(peerId: string): Promise<void> {
const channel = this.requireOpenChannel(peerId); const channel = this.requireOpenChannel(peerId);
if (!channel) { if (!channel) {
return; return;
} }
if (this.hasVoiceCallConflict(peerId) || this.outgoingVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId) { if (
this.error.set('Finish the current voice call before starting another one.'); this.hasVoiceCallConflict(peerId)
|| this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId
) {
this.error.set('Finish the current call before starting another one.');
return;
}
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) {
return; return;
} }
this.error.set(null); this.error.set(null);
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(peerId); this.outgoingVoiceCallPeerId.set(peerId);
channel.send(JSON.stringify({ type: 'voice-call-offer' } satisfies DataEnvelope)); this.upsertCallMode(this.outgoingCallModes, peerId, mode);
this.addSystemMessage(peerId, 'Calling peer.'); channel.send(JSON.stringify({ type: 'voice-call-offer', mode } satisfies DataEnvelope));
this.addSystemMessage(peerId, mode === 'video' ? 'Calling peer with video.' : 'Calling peer with audio only.');
} }
async acceptVoiceCall(peerId: string): Promise<void> { async acceptVoiceCall(peerId: string): Promise<void> {
@@ -497,7 +424,8 @@ export class ChatSessionService {
return; return;
} }
const bundle = await this.ensureLocalAudioStream(peerId); const mode = this.callModeForPeer(peerId) ?? 'video';
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) { if (!bundle) {
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
@@ -507,10 +435,12 @@ export class ChatSessionService {
this.stopRingtone(); this.stopRingtone();
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.clearCallMode(this.incomingCallModes, peerId);
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(peerId); this.activeVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.activeCallModes, peerId, mode);
this.sendVoiceCallResponse(peerId, true); this.sendVoiceCallResponse(peerId, true);
this.addSystemMessage(peerId, 'Voice call connected.'); this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
@@ -520,15 +450,16 @@ export class ChatSessionService {
} }
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
void this.stopLocalCallStream(peerId, false);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call rejected.'); this.addSystemMessage(peerId, 'Call rejected.');
} }
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> { async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId || this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId; || this.localCallPeerId() === peerId;
if (!hadVoiceCall) { if (!hadVoiceCall) {
return; return;
@@ -538,10 +469,11 @@ export class ChatSessionService {
this.sendVoiceCallEnded(peerId); this.sendVoiceCallEnded(peerId);
} }
await this.stopLocalAudioStream(peerId, true); await this.stopLocalCallStream(peerId, true);
this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId); this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call ended.'); this.addSystemMessage(peerId, 'Call ended.');
} }
async registerAccessKey(label: string): Promise<void> { async registerAccessKey(label: string): Promise<void> {
@@ -1394,8 +1326,7 @@ export class ChatSessionService {
pendingCandidates: [], pendingCandidates: [],
pendingNegotiation: false, pendingNegotiation: false,
announceConnectionEvents: announce, announceConnectionEvents: announce,
cameraSenders: [], mediaSenders: [],
audioSenders: [],
}; };
bundle.pc.onicecandidate = (event) => { bundle.pc.onicecandidate = (event) => {
@@ -1443,7 +1374,6 @@ export class ChatSessionService {
bundle.remoteCameraStream = remoteStream; bundle.remoteCameraStream = remoteStream;
this.upsertRemoteVideoStream(peerId, remoteStream); this.upsertRemoteVideoStream(peerId, remoteStream);
this.remoteVideoModalPeerId.set(peerId);
event.track.onended = () => { event.track.onended = () => {
if (!bundle.remoteCameraStream) { if (!bundle.remoteCameraStream) {
@@ -1578,15 +1508,8 @@ export class ChatSessionService {
case 'typing': case 'typing':
this.setPeerTyping(peerId, envelope.active); this.setPeerTyping(peerId, envelope.active);
break; break;
case 'camera-state':
if (envelope.active) {
this.remoteVideoModalPeerId.set(peerId);
} else {
this.clearRemoteVideoState(peerId);
}
break;
case 'voice-call-offer': case 'voice-call-offer':
this.handleIncomingVoiceCallOffer(peerId); this.handleIncomingVoiceCallOffer(peerId, envelope.mode);
break; break;
case 'voice-call-response': case 'voice-call-response':
void this.handleVoiceCallResponse(peerId, envelope.accepted); void this.handleVoiceCallResponse(peerId, envelope.accepted);
@@ -1665,16 +1588,6 @@ export class ChatSessionService {
}); });
} }
private sendCameraState(peerId: string, active: boolean): void {
const channel = this.peerBundles.get(peerId)?.channel;
if (!channel || channel.readyState !== 'open') {
return;
}
channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope));
}
private sendVoiceCallResponse(peerId: string, accepted: boolean): void { private sendVoiceCallResponse(peerId: string, accepted: boolean): void {
const channel = this.peerBundles.get(peerId)?.channel; const channel = this.peerBundles.get(peerId)?.channel;
@@ -1760,35 +1673,37 @@ export class ChatSessionService {
} }
private hasVoiceCallConflict(peerId: string): boolean { private hasVoiceCallConflict(peerId: string): boolean {
return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId()] return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId(), this.localCallPeerId()]
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId); .some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
} }
private async ensureLocalAudioStream(peerId: string): Promise<PeerBundle | null> { private async ensureLocalCallStream(peerId: string, mode: CallMode): Promise<PeerBundle | null> {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set('This browser does not support microphone capture.'); this.error.set(mode === 'video'
? 'This browser does not support camera and microphone capture.'
: 'This browser does not support microphone capture.');
return null; return null;
} }
if (this.activeAudioPeerId() && this.activeAudioPeerId() !== peerId) { if (this.localCallPeerId() && this.localCallPeerId() !== peerId) {
this.error.set('Finish the current voice call before starting another one.'); this.error.set('Finish the current call before starting another one.');
return null; return null;
} }
const bundle = this.ensurePeerBundle(peerId, true); const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localAudioStream) { if (bundle.localCallStream) {
return bundle; return bundle;
} }
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
video: false, video: mode === 'video',
}); });
bundle.localAudioStream = stream; bundle.localCallStream = stream;
bundle.audioSenders = stream.getTracks().map((track) => { bundle.mediaSenders = stream.getTracks().map((track) => {
track.onended = () => { track.onended = () => {
void this.endVoiceCall(peerId); void this.endVoiceCall(peerId);
}; };
@@ -1796,49 +1711,54 @@ export class ChatSessionService {
return bundle.pc.addTrack(track, stream); return bundle.pc.addTrack(track, stream);
}); });
this.activeAudioPeerId.set(peerId); this.localCallPeerId.set(peerId);
this.upsertLocalCallStream(peerId, stream);
return bundle; return bundle;
} catch { } catch {
this.error.set('Could not start microphone capture for the voice call.'); this.error.set(mode === 'video'
? 'Could not start camera and microphone capture for the call.'
: 'Could not start microphone capture for the call.');
return null; return null;
} }
} }
private async stopLocalAudioStream(peerId: string, renegotiate: boolean): Promise<void> { private async stopLocalCallStream(peerId: string, renegotiate: boolean): Promise<void> {
const bundle = this.peerBundles.get(peerId); const bundle = this.peerBundles.get(peerId);
if (!bundle?.localAudioStream && this.activeAudioPeerId() !== peerId) { if (!bundle?.localCallStream && this.localCallPeerId() !== peerId) {
return; return;
} }
if (bundle) { if (bundle) {
for (const sender of bundle.audioSenders) { for (const sender of bundle.mediaSenders) {
bundle.pc.removeTrack(sender); bundle.pc.removeTrack(sender);
} }
bundle.audioSenders = []; bundle.mediaSenders = [];
if (bundle.localAudioStream) { if (bundle.localCallStream) {
for (const track of bundle.localAudioStream.getTracks()) { for (const track of bundle.localCallStream.getTracks()) {
track.onended = null; track.onended = null;
track.stop(); track.stop();
} }
} }
bundle.localAudioStream = undefined; bundle.localCallStream = undefined;
} }
if (this.activeAudioPeerId() === peerId) { if (this.localCallPeerId() === peerId) {
this.activeAudioPeerId.set(null); this.localCallPeerId.set(null);
} }
this.clearLocalCallStream(peerId);
if (renegotiate && bundle) { if (renegotiate && bundle) {
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
} }
private handleIncomingVoiceCallOffer(peerId: string): void { private handleIncomingVoiceCallOffer(peerId: string, mode: CallMode): void {
if (this.hasVoiceCallConflict(peerId) || this.activeAudioPeerId()) { if (this.hasVoiceCallConflict(peerId) || this.localCallPeerId()) {
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
return; return;
} }
@@ -1846,8 +1766,9 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
this.incomingVoiceCallPeerId.set(peerId); this.incomingVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.incomingCallModes, peerId, mode);
this.startRingtone(); this.startRingtone();
this.addSystemMessage(peerId, 'Incoming voice call.'); this.addSystemMessage(peerId, mode === 'video' ? 'Incoming video call.' : 'Incoming audio call.');
} }
private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> { private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> {
@@ -1858,19 +1779,24 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
if (!accepted) { if (!accepted) {
this.addSystemMessage(peerId, 'Voice call declined.'); this.clearCallMode(this.outgoingCallModes, peerId);
await this.stopLocalCallStream(peerId, true);
this.addSystemMessage(peerId, 'Call declined.');
return; return;
} }
const mode = this.callModeForPeer(peerId) ?? 'video';
this.activeVoiceCallPeerId.set(peerId); this.activeVoiceCallPeerId.set(peerId);
const bundle = await this.ensureLocalAudioStream(peerId); this.clearCallMode(this.outgoingCallModes, peerId);
this.upsertCallMode(this.activeCallModes, peerId, mode);
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) { if (!bundle) {
await this.endVoiceCall(peerId); await this.endVoiceCall(peerId);
return; return;
} }
this.addSystemMessage(peerId, 'Voice call connected.'); this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
@@ -1878,14 +1804,15 @@ export class ChatSessionService {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId || this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId; || this.localCallPeerId() === peerId;
await this.stopLocalAudioStream(peerId, true); await this.stopLocalCallStream(peerId, true);
this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId); this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
if (hadVoiceCall) { if (hadVoiceCall) {
this.addSystemMessage(peerId, 'Voice call ended.'); this.addSystemMessage(peerId, 'Call ended.');
} }
} }
@@ -1902,27 +1829,18 @@ export class ChatSessionService {
return; return;
} }
if (bundle.localCameraStream) { if (bundle.localCallStream) {
for (const track of bundle.localCameraStream.getTracks()) { for (const track of bundle.localCallStream.getTracks()) {
track.onended = null; track.onended = null;
track.stop(); track.stop();
} }
} }
if (bundle.localAudioStream) { if (this.localCallPeerId() === peerId) {
for (const track of bundle.localAudioStream.getTracks()) { this.localCallPeerId.set(null);
track.onended = null;
track.stop();
}
} }
if (this.activeCameraPeerId() === peerId) { this.clearLocalCallStream(peerId);
this.activeCameraPeerId.set(null);
}
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
}
bundle.channel?.close(); bundle.channel?.close();
bundle.pc.close(); bundle.pc.close();
@@ -2107,11 +2025,13 @@ export class ChatSessionService {
this.releasePreloadedRingtone(); this.releasePreloadedRingtone();
this.pendingImageGenerationRequests.clear(); this.pendingImageGenerationRequests.clear();
this.rejectPendingSpeechTranscriptions('Session ended during dictation.'); this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
this.incomingCallModes.set([]);
this.outgoingCallModes.set([]);
this.activeCallModes.set([]);
this.localCallStreams.set([]);
this.remoteVideoStreams.set([]); this.remoteVideoStreams.set([]);
this.remoteAudioStreams.set([]); this.remoteAudioStreams.set([]);
this.remoteVideoModalPeerId.set(null); this.localCallPeerId.set(null);
this.activeCameraPeerId.set(null);
this.activeAudioPeerId.set(null);
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
@@ -2557,6 +2477,38 @@ export class ChatSessionService {
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
} }
private upsertLocalCallStream(peerId: string, stream: MediaStream): void {
this.localCallStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
if (existingIndex === -1) {
return [...entries, { peerId, stream }];
}
const nextEntries = [...entries];
nextEntries[existingIndex] = { peerId, stream };
return nextEntries;
});
}
private upsertCallMode(
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
peerId: string,
mode: CallMode,
): void {
store.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
if (existingIndex === -1) {
return [...entries, { peerId, mode }];
}
const nextEntries = [...entries];
nextEntries[existingIndex] = { peerId, mode };
return nextEntries;
});
}
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void { private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
this.remoteVideoStreams.update((entries) => { this.remoteVideoStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId); const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
@@ -2587,29 +2539,39 @@ export class ChatSessionService {
private clearRemoteVideoState(peerId: string): void { private clearRemoteVideoState(peerId: string): void {
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
} }
private clearRemoteAudioState(peerId: string): void { private clearRemoteAudioState(peerId: string): void {
this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
} }
private clearLocalCallStream(peerId: string): void {
this.localCallStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
}
private clearCallMode(
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
peerId: string,
): void {
store.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
}
private clearVoiceCallSignals(peerId: string): void { private clearVoiceCallSignals(peerId: string): void {
if (this.incomingVoiceCallPeerId() === peerId) { if (this.incomingVoiceCallPeerId() === peerId) {
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.stopRingtone(); this.stopRingtone();
} }
this.clearCallMode(this.incomingCallModes, peerId);
if (this.outgoingVoiceCallPeerId() === peerId) { if (this.outgoingVoiceCallPeerId() === peerId) {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
} }
this.clearCallMode(this.outgoingCallModes, peerId);
if (this.activeVoiceCallPeerId() === peerId) { if (this.activeVoiceCallPeerId() === peerId) {
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
} }
this.clearCallMode(this.activeCallModes, peerId);
} }
private startRingtone(): void { private startRingtone(): void {

View File

@@ -106,6 +106,8 @@ export interface ChatEntry {
downloadUrl?: string; downloadUrl?: string;
} }
export type CallMode = 'audio' | 'video';
export type SignalPayload = export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit } | { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit }; | { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
@@ -179,12 +181,9 @@ export type DataEnvelope =
type: 'typing'; type: 'typing';
active: boolean; active: boolean;
} }
| {
type: 'camera-state';
active: boolean;
}
| { | {
type: 'voice-call-offer'; type: 'voice-call-offer';
mode: CallMode;
} }
| { | {
type: 'voice-call-response'; type: 'voice-call-response';

View File

@@ -0,0 +1,155 @@
:host {
display: contents;
}
.call-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1250;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top, rgba(78, 114, 255, 0.18), transparent 34%),
rgba(3, 8, 14, 0.82);
backdrop-filter: blur(16px);
}
.call-modal-card {
width: min(100%, 72rem);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.75rem;
background:
linear-gradient(180deg, rgba(9, 16, 28, 0.98), rgba(4, 8, 16, 0.96));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48);
}
.call-modal-header,
.call-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.25rem 0;
}
.call-modal-footer {
justify-content: flex-end;
padding: 1rem 1.25rem 1.25rem;
}
.call-modal-eyebrow {
margin-bottom: 0.35rem;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.64);
}
.call-modal-close {
width: 2.75rem;
height: 2.75rem;
border: 0;
border-radius: 999px;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.08);
font-size: 1.5rem;
line-height: 1;
}
.call-modal-stage {
padding: 1.25rem;
}
.call-video-panel {
position: relative;
min-height: min(72vh, 42rem);
overflow: hidden;
border-radius: 1.35rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
}
.call-video-panel-local {
position: absolute;
right: 1rem;
bottom: 1rem;
width: min(22vw, 12rem);
min-height: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
z-index: 1;
}
.call-video-label {
position: absolute;
top: 0.85rem;
left: 0.85rem;
z-index: 1;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.34);
backdrop-filter: blur(8px);
}
.call-video-player,
.call-video-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(140, 191, 255, 0.18), transparent 36%),
#03070f;
aspect-ratio: 16 / 10;
}
.call-video-player {
display: block;
object-fit: cover;
}
.call-video-player-local {
transform: scaleX(-1);
}
.call-video-placeholder {
padding: 1.25rem;
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-size: 0.98rem;
}
.call-video-placeholder-local {
min-height: 8rem;
font-size: 0.82rem;
}
@media (max-width: 767.98px) {
.call-modal-backdrop {
padding: 0.9rem;
}
.call-modal-card {
border-radius: 1.4rem;
}
.call-video-panel {
min-height: 18rem;
}
.call-video-panel-local {
right: 0.75rem;
bottom: 0.75rem;
width: min(38vw, 8.5rem);
}
.call-modal-header,
.call-modal-footer {
padding-inline: 1rem;
}
}

View File

@@ -0,0 +1,168 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild,
} from '@angular/core';
import type { CallMode } from './models';
@Component({
selector: 'app-peer-call-modal',
imports: [CommonModule],
template: `
@if (visible) {
<div class="call-modal-backdrop">
<section class="call-modal-card" (click)="$event.stopPropagation()">
<header class="call-modal-header">
<div>
<p class="call-modal-eyebrow">Private {{ callMode === 'audio' ? 'audio' : 'video' }} call</p>
<h2 class="h4 mb-1">{{ peerName }}</h2>
<p class="small mb-0">{{ statusText }}</p>
</div>
<button
class="call-modal-close"
type="button"
(click)="requestDismiss()"
[attr.aria-label]="callState === 'incoming' ? 'Decline call' : 'End call'"
>
×
</button>
</header>
<div class="call-modal-stage">
<section class="call-video-panel call-video-panel-remote">
<div class="call-video-label">{{ callMode === 'audio' ? 'Peer audio' : 'Peer' }}</div>
@if (callMode === 'video' && remoteStream) {
<video #remoteVideoElement class="call-video-player" autoplay playsinline></video>
} @else {
<div class="call-video-placeholder">
{{
callMode === 'audio'
? 'Audio-only call in progress.'
: callState === 'incoming'
? 'Waiting for you to join.'
: 'Waiting for peer video…'
}}
</div>
}
<section class="call-video-panel call-video-panel-local">
<div class="call-video-label">You</div>
@if (callMode === 'video' && localStream) {
<video #localVideoElement class="call-video-player call-video-player-local" autoplay playsinline></video>
} @else {
<div class="call-video-placeholder call-video-placeholder-local">
{{ callMode === 'audio' ? 'Audio only' : callState === 'incoming' ? 'Camera starts when you accept.' : 'Starting your camera…' }}
</div>
}
</section>
</section>
</div>
<footer class="call-modal-footer">
@if (callState === 'incoming') {
<button class="btn btn-success" type="button" (click)="acceptRequested.emit()">
Accept
</button>
<button class="btn btn-outline-light" type="button" (click)="rejectRequested.emit()">
Reject
</button>
} @else {
<button class="btn btn-danger" type="button" (click)="hangupRequested.emit()">
{{ callState === 'outgoing' ? 'Cancel call' : 'End call' }}
</button>
}
</footer>
</section>
</div>
}
`,
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<void>();
@Output() readonly rejectRequested = new EventEmitter<void>();
@Output() readonly hangupRequested = new EventEmitter<void>();
@ViewChild('localVideoElement')
set localVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.localVideoElement = value;
this.syncVideoSources();
}
@ViewChild('remoteVideoElement')
set remoteVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.remoteVideoElement = value;
this.syncVideoSources();
}
private localVideoElement?: ElementRef<HTMLVideoElement>;
private remoteVideoElement?: ElementRef<HTMLVideoElement>;
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;
}
}

View File

@@ -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;
}

View File

@@ -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) {
<div class="video-modal-backdrop" (click)="requestClose()">
<section class="video-modal-card" (click)="$event.stopPropagation()">
<div class="video-modal-header">
<div>
<h2 class="h5 mb-1">{{ title }}</h2>
<p class="small mb-0">Live webcam capture from your peer.</p>
</div>
<button class="video-modal-close" type="button" (click)="requestClose()" aria-label="Close live video">
×
</button>
</div>
<div class="video-modal-body">
<video #videoElement class="video-modal-player" autoplay playsinline></video>
</div>
</section>
</div>
}
`,
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<void>();
@ViewChild('videoElement')
set videoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.videoElement = value;
this.syncVideoSource();
}
private videoElement?: ElementRef<HTMLVideoElement>;
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;
}
}