Many features
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Component, computed, effect, ElementRef, inject, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
@@ -15,7 +15,7 @@ import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
export class ChatPageComponent {
|
||||
export class ChatPageComponent implements OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||
@@ -23,10 +23,22 @@ export class ChatPageComponent {
|
||||
});
|
||||
private composerSelectionStart = 0;
|
||||
private composerSelectionEnd = 0;
|
||||
private voiceRecorder: MediaRecorder | null = null;
|
||||
private voiceStream: MediaStream | null = null;
|
||||
private voiceChunks: Blob[] = [];
|
||||
private discardRecordedVoice = false;
|
||||
private recordingPeerId: string | null = null;
|
||||
@ViewChild('callAudioElement')
|
||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||
this.callAudioElement = value;
|
||||
this.syncCallAudioSource();
|
||||
}
|
||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||
|
||||
messageText = '';
|
||||
readonly forwardingEntryId = signal<string | null>(null);
|
||||
readonly emojiPickerOpen = signal(false);
|
||||
readonly isRecordingVoice = signal(false);
|
||||
readonly emojiOptions = [
|
||||
'😀', '😁', '😂', '🤣', '😊',
|
||||
'😉', '😍', '😘', '😎', '🤔',
|
||||
@@ -40,15 +52,65 @@ export class ChatPageComponent {
|
||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||
readonly currentUser = computed(() => this.session.currentUser());
|
||||
readonly incomingVoiceCallPeer = computed(() => {
|
||||
const peerId = this.session.incomingVoiceCallPeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
readonly conversation = computed(() =>
|
||||
this.session
|
||||
.messages()
|
||||
.filter((entry) => entry.peerId === this.peerId()),
|
||||
);
|
||||
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
||||
readonly remoteCallAudioStream = computed(() =>
|
||||
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
|
||||
);
|
||||
readonly remoteVideoModalVisible = computed(
|
||||
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
||||
);
|
||||
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
if (this.session.activeVoiceCallPeerId() === peerId) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (this.session.outgoingVoiceCallPeerId() === peerId) {
|
||||
return 'outgoing';
|
||||
}
|
||||
|
||||
if (this.session.incomingVoiceCallPeerId() === peerId) {
|
||||
return 'incoming';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
});
|
||||
readonly canStartSelectedVoiceCall = computed(() => {
|
||||
const selectedPeer = this.peer();
|
||||
|
||||
if (!selectedPeer || selectedPeer.channelState !== 'open') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activePeerId = this.session.activeVoiceCallPeerId();
|
||||
const outgoingPeerId = this.session.outgoingVoiceCallPeerId();
|
||||
const incomingPeerId = this.session.incomingVoiceCallPeerId();
|
||||
|
||||
return !activePeerId && !outgoingPeerId && !incomingPeerId;
|
||||
});
|
||||
readonly canEndSelectedVoiceCall = computed(() => {
|
||||
const peerId = this.peerId();
|
||||
|
||||
return !!peerId && (
|
||||
this.session.activeVoiceCallPeerId() === peerId ||
|
||||
this.session.outgoingVoiceCallPeerId() === peerId
|
||||
);
|
||||
});
|
||||
readonly webRtcState = computed<ConnectionState>(() => {
|
||||
const selectedPeer = this.peer();
|
||||
|
||||
@@ -82,6 +144,16 @@ export class ChatPageComponent {
|
||||
this.session.selectPeer(peerId);
|
||||
void this.session.connectToPeer(peerId);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.remoteCallAudioStream();
|
||||
this.syncCallAudioSource();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopVoiceRecording(true);
|
||||
this.detachCallAudioSource();
|
||||
}
|
||||
|
||||
async ensureConnection(): Promise<void> {
|
||||
@@ -190,6 +262,76 @@ export class ChatPageComponent {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async toggleVoiceRecording(): Promise<void> {
|
||||
if (this.isRecordingVoice()) {
|
||||
this.stopVoiceRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') {
|
||||
this.session.error.set('This browser does not support voice recording.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
||||
this.session.error.set('This browser cannot access the microphone.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const preferredMimeType = this.preferredVoiceMimeType();
|
||||
const recorder = preferredMimeType
|
||||
? new MediaRecorder(stream, { mimeType: preferredMimeType })
|
||||
: new MediaRecorder(stream);
|
||||
|
||||
this.voiceChunks = [];
|
||||
this.discardRecordedVoice = false;
|
||||
this.recordingPeerId = peerId;
|
||||
this.voiceStream = stream;
|
||||
this.voiceRecorder = recorder;
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.voiceChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
this.session.error.set('Could not record voice message.');
|
||||
this.cleanupVoiceRecording();
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const shouldDiscard = this.discardRecordedVoice;
|
||||
const targetPeerId = this.recordingPeerId;
|
||||
const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm';
|
||||
const blob = new Blob(this.voiceChunks, { type: mimeType });
|
||||
|
||||
this.cleanupVoiceRecording();
|
||||
|
||||
if (shouldDiscard || !targetPeerId || blob.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.session.sendVoiceMessage(targetPeerId, blob, mimeType);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
this.isRecordingVoice.set(true);
|
||||
this.session.error.set(null);
|
||||
} catch {
|
||||
this.session.error.set('Could not start microphone recording.');
|
||||
this.cleanupVoiceRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(entry: ChatEntry): Promise<void> {
|
||||
await this.session.deleteMessage(entry);
|
||||
}
|
||||
@@ -241,6 +383,34 @@ export class ChatPageComponent {
|
||||
await this.session.startCameraStream(peerId);
|
||||
}
|
||||
|
||||
async startVoiceCall(peerId: string): Promise<void> {
|
||||
await this.session.startVoiceCall(peerId);
|
||||
}
|
||||
|
||||
async endVoiceCall(peerId: string): Promise<void> {
|
||||
await this.session.endVoiceCall(peerId);
|
||||
}
|
||||
|
||||
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (peerId !== this.peerId()) {
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
}
|
||||
|
||||
await this.session.acceptVoiceCall(peerId);
|
||||
}
|
||||
|
||||
rejectIncomingVoiceCall(peerId: string): void {
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.rejectVoiceCall(peerId);
|
||||
}
|
||||
|
||||
isImageEntry(entry: ChatEntry): boolean {
|
||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||
}
|
||||
@@ -271,6 +441,10 @@ export class ChatPageComponent {
|
||||
return this.session.typingPeerIds().includes(peerId);
|
||||
}
|
||||
|
||||
isPeerUnread(peerId: string): boolean {
|
||||
return this.session.unreadPeerIds().includes(peerId);
|
||||
}
|
||||
|
||||
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
||||
if (state === 'connected') {
|
||||
return 'ok';
|
||||
@@ -308,9 +482,85 @@ export class ChatPageComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopVoiceRecording(true);
|
||||
this.forwardingEntryId.set(null);
|
||||
this.emojiPickerOpen.set(false);
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
}
|
||||
|
||||
private stopVoiceRecording(discard: boolean): void {
|
||||
const recorder = this.voiceRecorder;
|
||||
|
||||
if (!recorder) {
|
||||
this.discardRecordedVoice = discard;
|
||||
this.cleanupVoiceRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
this.discardRecordedVoice = discard;
|
||||
|
||||
if (recorder.state !== 'inactive') {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupVoiceRecording();
|
||||
}
|
||||
|
||||
private cleanupVoiceRecording(): void {
|
||||
if (this.voiceStream) {
|
||||
for (const track of this.voiceStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
this.voiceRecorder = null;
|
||||
this.voiceStream = null;
|
||||
this.voiceChunks = [];
|
||||
this.recordingPeerId = null;
|
||||
this.isRecordingVoice.set(false);
|
||||
}
|
||||
|
||||
private preferredVoiceMimeType(): string {
|
||||
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
|
||||
|
||||
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '';
|
||||
}
|
||||
|
||||
private syncCallAudioSource(): void {
|
||||
const audio = this.callAudioElement?.nativeElement;
|
||||
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.remoteCallAudioStream();
|
||||
|
||||
audio.srcObject = stream;
|
||||
|
||||
if (stream) {
|
||||
void audio.play().catch(() => {
|
||||
// Autoplay may wait for a browser gesture.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
}
|
||||
|
||||
private detachCallAudioSource(): void {
|
||||
const audio = this.callAudioElement?.nativeElement;
|
||||
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
audio.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user