bandwidth meter

This commit is contained in:
2026-03-11 18:12:08 +01:00
parent ae59d3deac
commit a296b64c0f
4 changed files with 226 additions and 127 deletions

View File

@@ -219,136 +219,145 @@
placeholder="Write a text message to your peer"
></textarea>
<div class="composer-toolbar">
@if (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(selectedPeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
<div class="composer-toolbar">
<div class="composer-actions">
@if (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(selectedPeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End call"
aria-label="End call"
>
🛑
</button>
}
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End call"
aria-label="End call"
>
🛑
</button>
}
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="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()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="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()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<button
class="composer-dictation"
type="button"
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
<button
class="composer-dictation"
type="button"
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="selectedPeer.channelState !== 'open'"
(change)="sendFile(selectedPeer.id, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="selectedPeer.channelState !== 'open'"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="selectedPeer.channelState !== 'open'"
(change)="sendFile(selectedPeer.id, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="selectedPeer.channelState !== 'open'"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<button
class="composer-image-generate"
type="button"
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
(click)="requestGeneratedImage()"
title="Generate image from prompt"
aria-label="Generate image from prompt"
>
🖼️
</button>
<button
class="composer-image-generate"
type="button"
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
(click)="requestGeneratedImage()"
title="Generate image from prompt"
aria-label="Generate image from prompt"
>
🖼️
</button>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</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>

View File

@@ -526,10 +526,41 @@
.composer-toolbar {
display: flex;
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;
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 {
position: relative;
}
@@ -585,6 +616,11 @@
background: var(--badge-background);
}
.composer-plus {
font-size: 1.76rem;
font-weight: 700;
}
.composer-dictation {
color: var(--page-text);
background: linear-gradient(135deg, #f6d8ff, #ffcadb);

View File

@@ -125,6 +125,11 @@ export class ChatPageComponent implements OnDestroy {
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly lastIncomingReceiveMetric = computed(() => {
const metric = this.session.lastIncomingReceiveMetric();
return metric?.peerId === this.peerId() ? metric : null;
});
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
);

View File

@@ -43,6 +43,8 @@ type IncomingFileTransfer = {
authorName: string;
chunks: ArrayBuffer[];
receivedBytes: number;
controlBytes: number;
startedAtMs: number;
};
type PersistedBinary = string | ArrayBuffer;
@@ -146,6 +148,7 @@ export class ChatSessionService {
readonly status = signal('Disconnected from signaling server.');
readonly error = signal<string | null>(null);
readonly notice = signal<string | null>(null);
readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null);
readonly webAuthnSupported = signal(
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined' &&
@@ -1502,8 +1505,15 @@ export class ChatSessionService {
};
channel.onmessage = (event) => {
const receivedAtMs = this.nowMs();
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;
}
@@ -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) {
case 'text':
this.pushMessage({
@@ -1523,6 +1533,7 @@ export class ChatSessionService {
authorLabel: envelope.authorName,
text: envelope.body,
});
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
break;
case 'json':
this.pushMessage({
@@ -1534,6 +1545,7 @@ export class ChatSessionService {
authorLabel: envelope.authorName,
payload: envelope.body,
});
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
break;
case 'file-meta':
this.incomingFiles.set(peerId, {
@@ -1546,11 +1558,13 @@ export class ChatSessionService {
authorName: envelope.authorName,
chunks: [],
receivedBytes: 0,
controlBytes: rawSizeBytes,
startedAtMs: receivedAtMs,
});
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
break;
case 'file-complete':
void this.finalizeIncomingFile(peerId, envelope.id);
void this.finalizeIncomingFile(peerId, envelope.id, rawSizeBytes, receivedAtMs);
break;
case 'typing':
this.setPeerTyping(peerId, envelope.active);
@@ -1580,7 +1594,12 @@ export class ChatSessionService {
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);
if (!transfer || transfer.id !== transferId) {
@@ -1619,6 +1638,12 @@ export class ChatSessionService {
previewMimeType,
previewDownloadUrl,
}, blob, previewBlob);
this.recordIncomingReceiveMetric(
peerId,
transfer.controlBytes + transfer.receivedBytes + completionEnvelopeBytes,
transfer.startedAtMs,
completedAtMs,
);
}
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
@@ -3132,6 +3157,30 @@ export class ChatSessionService {
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 {
const value = this.readStorage('privatechat.user');