bandwidth meter
This commit is contained in:
@@ -219,136 +219,145 @@
|
|||||||
placeholder="Write a text message to your peer"
|
placeholder="Write a text message to your peer"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div class="composer-toolbar">
|
<div class="composer-toolbar">
|
||||||
@if (peer(); as selectedPeer) {
|
<div class="composer-actions">
|
||||||
<button
|
@if (peer(); as selectedPeer) {
|
||||||
class="composer-call"
|
<button
|
||||||
type="button"
|
class="composer-call"
|
||||||
[disabled]="!canStartSelectedVoiceCall()"
|
type="button"
|
||||||
(click)="openCallChoice(selectedPeer.id)"
|
[disabled]="!canStartSelectedVoiceCall()"
|
||||||
title="Start call"
|
(click)="openCallChoice(selectedPeer.id)"
|
||||||
aria-label="Start call"
|
title="Start call"
|
||||||
>
|
aria-label="Start call"
|
||||||
📞
|
>
|
||||||
</button>
|
📞
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (canEndSelectedVoiceCall()) {
|
@if (canEndSelectedVoiceCall()) {
|
||||||
<button
|
<button
|
||||||
class="composer-hangup"
|
class="composer-hangup"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="endVoiceCall(selectedPeer.id)"
|
(click)="endVoiceCall(selectedPeer.id)"
|
||||||
title="End call"
|
title="End call"
|
||||||
aria-label="End call"
|
aria-label="End call"
|
||||||
>
|
>
|
||||||
🛑
|
🛑
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-voice"
|
class="composer-voice"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
||||||
(click)="toggleVoiceRecording()"
|
(click)="toggleVoiceRecording()"
|
||||||
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
[class.composer-voice-recording]="isRecordingVoice()"
|
[class.composer-voice-recording]="isRecordingVoice()"
|
||||||
>
|
>
|
||||||
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-dictation"
|
class="composer-dictation"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
||||||
(click)="toggleDictation(composerTextarea)"
|
(click)="toggleDictation(composerTextarea)"
|
||||||
[title]="
|
[title]="
|
||||||
isDictating()
|
isDictating()
|
||||||
? 'Stop dictation and transcribe'
|
? 'Stop dictation and transcribe'
|
||||||
: isTranscribingDictation()
|
: isTranscribingDictation()
|
||||||
? 'Transcribing dictated audio'
|
? 'Transcribing dictated audio'
|
||||||
: 'Start dictation'
|
: 'Start dictation'
|
||||||
"
|
"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="
|
||||||
isDictating()
|
isDictating()
|
||||||
? 'Stop dictation and transcribe'
|
? 'Stop dictation and transcribe'
|
||||||
: isTranscribingDictation()
|
: isTranscribingDictation()
|
||||||
? 'Transcribing dictated audio'
|
? 'Transcribing dictated audio'
|
||||||
: 'Start dictation'
|
: 'Start dictation'
|
||||||
"
|
"
|
||||||
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||||
>
|
>
|
||||||
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
class="composer-file-input"
|
class="composer-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
(change)="sendFile(selectedPeer.id, fileInput)"
|
(change)="sendFile(selectedPeer.id, fileInput)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="composer-plus"
|
class="composer-plus"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
(click)="fileInput.click()"
|
(click)="fileInput.click()"
|
||||||
title="Send file"
|
title="Send file"
|
||||||
aria-label="Send file"
|
aria-label="Send file"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-image-generate"
|
class="composer-image-generate"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
||||||
(click)="requestGeneratedImage()"
|
(click)="requestGeneratedImage()"
|
||||||
title="Generate image from prompt"
|
title="Generate image from prompt"
|
||||||
aria-label="Generate image from prompt"
|
aria-label="Generate image from prompt"
|
||||||
>
|
>
|
||||||
🖼️
|
🖼️
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="composer-emoji-picker-shell">
|
<div class="composer-emoji-picker-shell">
|
||||||
@if (emojiPickerOpen()) {
|
@if (emojiPickerOpen()) {
|
||||||
<div class="composer-emoji-picker">
|
<div class="composer-emoji-picker">
|
||||||
@for (emoji of emojiOptions; track emoji) {
|
@for (emoji of emojiOptions; track emoji) {
|
||||||
<button
|
<button
|
||||||
class="composer-emoji-option"
|
class="composer-emoji-option"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="insertEmoji(emoji, composerTextarea)"
|
(click)="insertEmoji(emoji, composerTextarea)"
|
||||||
[attr.aria-label]="'Insert ' + emoji"
|
[attr.aria-label]="'Insert ' + emoji"
|
||||||
[title]="'Insert ' + emoji"
|
[title]="'Insert ' + emoji"
|
||||||
>
|
>
|
||||||
{{ emoji }}
|
{{ emoji }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
class="composer-emoji-trigger"
|
class="composer-emoji-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
(click)="toggleEmojiPicker($event)"
|
(click)="toggleEmojiPicker($event)"
|
||||||
title="Insert emoji"
|
title="Insert emoji"
|
||||||
aria-label="Insert emoji"
|
aria-label="Insert emoji"
|
||||||
>
|
>
|
||||||
😀
|
😀
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="send-emoji"
|
class="send-emoji"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
(click)="sendMessage()"
|
(click)="sendMessage()"
|
||||||
title="Send message"
|
title="Send message"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
✅
|
✅
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (lastIncomingReceiveMetric(); as receiveMetric) {
|
||||||
|
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
|
||||||
|
<span class="composer-receive-speed-label">Rx</span>
|
||||||
|
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -526,10 +526,41 @@
|
|||||||
.composer-toolbar {
|
.composer-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-receive-speed {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-receive-speed-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-receive-speed-value {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-emoji-picker-shell {
|
.composer-emoji-picker-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -585,6 +616,11 @@
|
|||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-plus {
|
||||||
|
font-size: 1.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-dictation {
|
.composer-dictation {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
.messages()
|
.messages()
|
||||||
.filter((entry) => entry.peerId === this.peerId()),
|
.filter((entry) => entry.peerId === this.peerId()),
|
||||||
);
|
);
|
||||||
|
readonly lastIncomingReceiveMetric = computed(() => {
|
||||||
|
const metric = this.session.lastIncomingReceiveMetric();
|
||||||
|
|
||||||
|
return metric?.peerId === this.peerId() ? metric : null;
|
||||||
|
});
|
||||||
readonly remoteCallAudioStream = computed(() =>
|
readonly remoteCallAudioStream = computed(() =>
|
||||||
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type IncomingFileTransfer = {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
chunks: ArrayBuffer[];
|
chunks: ArrayBuffer[];
|
||||||
receivedBytes: number;
|
receivedBytes: number;
|
||||||
|
controlBytes: number;
|
||||||
|
startedAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistedBinary = string | ArrayBuffer;
|
type PersistedBinary = string | ArrayBuffer;
|
||||||
@@ -146,6 +148,7 @@ export class ChatSessionService {
|
|||||||
readonly status = signal('Disconnected from signaling server.');
|
readonly status = signal('Disconnected from signaling server.');
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly notice = signal<string | null>(null);
|
readonly notice = signal<string | null>(null);
|
||||||
|
readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null);
|
||||||
readonly webAuthnSupported = signal(
|
readonly webAuthnSupported = signal(
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
typeof window.PublicKeyCredential !== 'undefined' &&
|
typeof window.PublicKeyCredential !== 'undefined' &&
|
||||||
@@ -1502,8 +1505,15 @@ export class ChatSessionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
|
const receivedAtMs = this.nowMs();
|
||||||
|
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
this.handleChannelEnvelope(peerId, JSON.parse(event.data) as DataEnvelope);
|
this.handleChannelEnvelope(
|
||||||
|
peerId,
|
||||||
|
JSON.parse(event.data) as DataEnvelope,
|
||||||
|
receivedAtMs,
|
||||||
|
this.measureStringBytes(event.data),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,7 +1521,7 @@ export class ChatSessionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChannelEnvelope(peerId: string, envelope: DataEnvelope): void {
|
private handleChannelEnvelope(peerId: string, envelope: DataEnvelope, receivedAtMs: number, rawSizeBytes: number): void {
|
||||||
switch (envelope.type) {
|
switch (envelope.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
@@ -1523,6 +1533,7 @@ export class ChatSessionService {
|
|||||||
authorLabel: envelope.authorName,
|
authorLabel: envelope.authorName,
|
||||||
text: envelope.body,
|
text: envelope.body,
|
||||||
});
|
});
|
||||||
|
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'json':
|
case 'json':
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
@@ -1534,6 +1545,7 @@ export class ChatSessionService {
|
|||||||
authorLabel: envelope.authorName,
|
authorLabel: envelope.authorName,
|
||||||
payload: envelope.body,
|
payload: envelope.body,
|
||||||
});
|
});
|
||||||
|
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'file-meta':
|
case 'file-meta':
|
||||||
this.incomingFiles.set(peerId, {
|
this.incomingFiles.set(peerId, {
|
||||||
@@ -1546,11 +1558,13 @@ export class ChatSessionService {
|
|||||||
authorName: envelope.authorName,
|
authorName: envelope.authorName,
|
||||||
chunks: [],
|
chunks: [],
|
||||||
receivedBytes: 0,
|
receivedBytes: 0,
|
||||||
|
controlBytes: rawSizeBytes,
|
||||||
|
startedAtMs: receivedAtMs,
|
||||||
});
|
});
|
||||||
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
||||||
break;
|
break;
|
||||||
case 'file-complete':
|
case 'file-complete':
|
||||||
void this.finalizeIncomingFile(peerId, envelope.id);
|
void this.finalizeIncomingFile(peerId, envelope.id, rawSizeBytes, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'typing':
|
case 'typing':
|
||||||
this.setPeerTyping(peerId, envelope.active);
|
this.setPeerTyping(peerId, envelope.active);
|
||||||
@@ -1580,7 +1594,12 @@ export class ChatSessionService {
|
|||||||
transfer.receivedBytes += arrayBuffer.byteLength;
|
transfer.receivedBytes += arrayBuffer.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finalizeIncomingFile(peerId: string, transferId: string): Promise<void> {
|
private async finalizeIncomingFile(
|
||||||
|
peerId: string,
|
||||||
|
transferId: string,
|
||||||
|
completionEnvelopeBytes: number,
|
||||||
|
completedAtMs: number,
|
||||||
|
): Promise<void> {
|
||||||
const transfer = this.incomingFiles.get(peerId);
|
const transfer = this.incomingFiles.get(peerId);
|
||||||
|
|
||||||
if (!transfer || transfer.id !== transferId) {
|
if (!transfer || transfer.id !== transferId) {
|
||||||
@@ -1619,6 +1638,12 @@ export class ChatSessionService {
|
|||||||
previewMimeType,
|
previewMimeType,
|
||||||
previewDownloadUrl,
|
previewDownloadUrl,
|
||||||
}, blob, previewBlob);
|
}, blob, previewBlob);
|
||||||
|
this.recordIncomingReceiveMetric(
|
||||||
|
peerId,
|
||||||
|
transfer.controlBytes + transfer.receivedBytes + completionEnvelopeBytes,
|
||||||
|
transfer.startedAtMs,
|
||||||
|
completedAtMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
||||||
@@ -3132,6 +3157,30 @@ export class ChatSessionService {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private measureStringBytes(value: string): number {
|
||||||
|
return new TextEncoder().encode(value).byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nowMs(): number {
|
||||||
|
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordIncomingReceiveMetric(peerId: string, totalBytes: number, startedAtMs: number, endedAtMs: number): void {
|
||||||
|
if (!peerId || totalBytes <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSeconds = Math.max((endedAtMs - startedAtMs) / 1000, 0.001);
|
||||||
|
const mbps = (totalBytes * 8) / durationSeconds / 1_000_000;
|
||||||
|
|
||||||
|
this.lastIncomingReceiveMetric.set({
|
||||||
|
peerId,
|
||||||
|
mbps: Number.isFinite(mbps) ? mbps : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private readUserStorage(): UserProfile | null {
|
private readUserStorage(): UserProfile | null {
|
||||||
const value = this.readStorage('privatechat.user');
|
const value = this.readStorage('privatechat.user');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user