bandwidth meter
This commit is contained in:
@@ -220,6 +220,7 @@
|
||||
></textarea>
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<div class="composer-actions">
|
||||
@if (peer(); as selectedPeer) {
|
||||
<button
|
||||
class="composer-call"
|
||||
@@ -349,6 +350,14 @@
|
||||
✅
|
||||
</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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() ?? ''),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user