Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,129 @@
<div class="audio-player-shell">
<audio
#audioEl
[src]="src()"
preload="metadata"
(ended)="onPause()"
(loadedmetadata)="onLoadedMetadata()"
(pause)="onPause()"
(play)="onPlay()"
(timeupdate)="onTimeUpdate()"
(volumechange)="onVolumeChange()"
></audio>
<div class="audio-top-bar">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
@if (sizeLabel()) {
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
}
</div>
<button
type="button"
(click)="requestDownload()"
class="audio-control-btn"
title="Save to folder"
aria-label="Save audio to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
<div class="audio-body">
<button
type="button"
(click)="togglePlayback()"
class="audio-play-btn"
[title]="isPlaying() ? 'Pause' : 'Play'"
[attr.aria-label]="isPlaying() ? 'Pause audio' : 'Play audio'"
>
<ng-icon
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
class="w-5 h-5"
/>
</button>
<div class="audio-main">
<div
class="audio-waveform-panel"
[class.expanded]="waveformExpanded()"
[attr.aria-hidden]="!waveformExpanded()"
>
<div class="audio-waveform-shell">
<div
#waveformContainer
class="audio-waveform-container"
[class.invisible]="waveformLoading() || waveformUnavailable()"
></div>
@if (waveformLoading()) {
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform…</div>
} @else if (waveformUnavailable()) {
<div class="audio-waveform-overlay text-muted-foreground">
Couldnt render a waveform preview for this file, but playback still works.
</div>
}
</div>
</div>
<input
type="range"
min="0"
[max]="durationSeconds() || 0"
[value]="currentTimeSeconds()"
(input)="onSeek($event)"
class="seek-slider"
[style.background]="seekTrackBackground()"
aria-label="Seek audio"
/>
<div class="audio-controls-row">
<span class="audio-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
<div class="audio-actions-group">
<div class="audio-volume-group">
<button
type="button"
(click)="toggleMute()"
class="audio-control-btn"
[title]="isMuted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="isMuted() ? 'Unmute audio' : 'Mute audio'"
>
<ng-icon
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="w-4 h-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="displayVolumePercent()"
(input)="onVolumeInput($event)"
class="volume-slider"
[style.background]="volumeTrackBackground()"
aria-label="Audio volume"
/>
</div>
<button
type="button"
(click)="toggleWaveform()"
class="audio-control-btn"
[title]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
[attr.aria-label]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
>
<ng-icon
[name]="waveformToggleIcon()"
class="w-4 h-4"
/>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,255 @@
:host {
display: block;
max-width: 40rem;
}
.audio-player-shell {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
background:
radial-gradient(circle at top left, hsl(var(--primary) / 0.14), transparent 42%),
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--secondary) / 0.55) 100%);
box-shadow: 0 10px 28px rgb(0 0 0 / 18%);
}
.audio-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 0.875rem 0.625rem;
}
.audio-body {
display: flex;
align-items: stretch;
gap: 0.875rem;
padding: 0 0.875rem 0.875rem;
}
.audio-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
min-width: 3.25rem;
border: 1px solid hsl(var(--primary) / 0.35);
border-radius: 9999px;
color: hsl(var(--primary-foreground));
background: linear-gradient(180deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.76) 100%);
box-shadow: 0 10px 22px rgb(0 0 0 / 16%);
transition:
transform 0.16s ease,
filter 0.16s ease;
}
.audio-play-btn:hover {
transform: translateY(-1px);
filter: brightness(1.04);
}
.audio-main {
min-width: 0;
flex: 1;
}
.audio-waveform-panel {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.2s ease,
opacity 0.2s ease,
margin-bottom 0.2s ease;
}
.audio-waveform-panel.expanded {
max-height: 5.5rem;
opacity: 1;
margin-bottom: 0.75rem;
}
.audio-waveform-shell {
position: relative;
overflow: hidden;
height: 4.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 0.95rem;
background:
linear-gradient(180deg, hsl(var(--background) / 0.72) 0%, hsl(var(--secondary) / 0.28) 100%);
}
.audio-waveform-container {
width: 100%;
height: 100%;
}
.audio-waveform-container.invisible {
opacity: 0;
}
.audio-waveform-container ::part(wrapper) {
cursor: pointer;
}
.audio-waveform-container ::part(cursor) {
display: none;
}
.audio-waveform-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
text-align: center;
font-size: 0.6875rem;
line-height: 1.35;
pointer-events: none;
}
.audio-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.625rem;
}
.audio-time-label {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.audio-actions-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-volume-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--foreground));
background: hsl(var(--card) / 0.72);
transition:
background-color 0.16s ease,
border-color 0.16s ease,
transform 0.16s ease;
}
.audio-control-btn:hover {
border-color: hsl(var(--primary) / 0.75);
background: hsl(var(--primary) / 0.14);
transform: translateY(-1px);
}
.seek-slider,
.volume-slider {
-webkit-appearance: none;
appearance: none;
outline: none;
cursor: pointer;
}
.seek-slider {
width: 100%;
height: 6px;
margin-top: 0.75rem;
border-radius: 9999px;
}
.volume-slider {
width: 5.5rem;
height: 6px;
border-radius: 9999px;
}
.seek-slider::-webkit-slider-runnable-track,
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 9999px;
background: transparent;
}
.seek-slider::-webkit-slider-thumb,
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.seek-slider::-moz-range-track,
.volume-slider::-moz-range-track {
height: 6px;
border: none;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-moz-range-thumb,
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
@keyframes audio-wave-motion {
from {
transform: translateX(0);
}
to {
transform: translateX(-16px);
}
}
@media (width <= 640px) {
.audio-body {
gap: 0.75rem;
}
.audio-play-btn {
width: 3rem;
height: 3rem;
min-width: 3rem;
}
.audio-volume-group {
display: none;
}
.audio-waveform-shell {
height: 3.75rem;
}
.audio-controls-row {
margin-top: 0.5rem;
}
}

View File

@@ -0,0 +1,443 @@
import {
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import type WaveSurfer from 'wavesurfer.js';
import {
lucideChevronDown,
lucideChevronUp,
lucideDownload,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
const AUDIO_PLAYER_VOLUME_STORAGE_KEY = 'metoyou_audio_player_volume';
const DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT = 50;
function getInitialSharedAudioPlayerVolume(): number {
if (typeof window === 'undefined')
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
try {
const raw = window.localStorage.getItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY);
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 100)
return parsed;
} catch { /* ignore storage access issues */ }
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
}
function persistSharedAudioPlayerVolume(volumePercent: number): void {
if (typeof window === 'undefined')
return;
try {
window.localStorage.setItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY, String(volumePercent));
} catch { /* ignore storage access issues */ }
}
@Component({
selector: 'app-chat-audio-player',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideChevronDown,
lucideChevronUp,
lucideDownload,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './chat-audio-player.component.html',
styleUrl: './chat-audio-player.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ChatAudioPlayerComponent implements OnDestroy {
src = input.required<string>();
filename = input.required<string>();
sizeLabel = input<string>('');
downloadRequested = output<undefined>();
@ViewChild('audioEl') audioRef?: ElementRef<HTMLAudioElement>;
@ViewChild('waveformContainer') waveformContainer?: ElementRef<HTMLDivElement>;
isPlaying = signal(false);
isMuted = signal(false);
waveformExpanded = signal(false);
waveformLoading = signal(false);
waveformUnavailable = signal(false);
currentTimeSeconds = signal(0);
durationSeconds = signal(0);
volumePercent = signal(getInitialSharedAudioPlayerVolume());
private lastNonZeroVolume = signal(getInitialSharedAudioPlayerVolume());
private waveSurfer: WaveSurfer | null = null;
progressPercent = computed(() => {
const duration = this.durationSeconds();
if (duration <= 0)
return 0;
return (this.currentTimeSeconds() / duration) * 100;
});
seekTrackBackground = computed(() => this.buildSliderBackground(this.progressPercent()));
waveformToggleIcon = computed(() => this.waveformExpanded() ? 'lucideChevronUp' : 'lucideChevronDown');
displayVolumePercent = computed(() => this.isMuted() ? 0 : this.volumePercent());
volumeTrackBackground = computed(() => {
const volume = Math.max(0, Math.min(100, this.displayVolumePercent()));
return this.buildSliderBackground(volume);
});
constructor() {
effect(() => {
void this.src();
const storedVolume = getInitialSharedAudioPlayerVolume();
this.destroyWaveSurfer();
this.waveformExpanded.set(false);
this.waveformLoading.set(false);
this.waveformUnavailable.set(false);
this.currentTimeSeconds.set(0);
this.durationSeconds.set(0);
this.isPlaying.set(false);
this.isMuted.set(false);
this.volumePercent.set(storedVolume);
this.lastNonZeroVolume.set(storedVolume);
requestAnimationFrame(() => {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
audio.load();
this.applyAudioVolume(storedVolume);
});
});
}
ngOnDestroy(): void {
this.destroyWaveSurfer();
}
togglePlayback(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
if (audio.paused || audio.ended) {
void audio.play().catch(() => {
this.isPlaying.set(false);
});
return;
}
audio.pause();
}
toggleWaveform(): void {
const nextExpanded = !this.waveformExpanded();
this.waveformExpanded.set(nextExpanded);
if (nextExpanded) {
this.waveformUnavailable.set(false);
requestAnimationFrame(() => {
void this.ensureWaveformLoaded();
});
}
}
onLoadedMetadata(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.applyAudioVolume(this.volumePercent());
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
this.currentTimeSeconds.set(audio.currentTime || 0);
if (this.waveformExpanded() && !this.waveSurfer && !this.waveformLoading()) {
requestAnimationFrame(() => {
void this.ensureWaveformLoaded();
});
}
}
onTimeUpdate(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.currentTimeSeconds.set(audio.currentTime || 0);
}
onPlay(): void {
this.isPlaying.set(true);
}
onPause(): void {
this.isPlaying.set(false);
}
onSeek(event: Event): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
const nextTime = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(nextTime))
return;
audio.currentTime = nextTime;
this.currentTimeSeconds.set(nextTime);
}
onVolumeInput(event: Event): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
if (nextVolume <= 0) {
audio.volume = 0;
audio.muted = true;
this.isMuted.set(true);
return;
}
audio.volume = nextVolume / 100;
audio.muted = false;
this.volumePercent.set(nextVolume);
this.lastNonZeroVolume.set(nextVolume);
this.isMuted.set(false);
this.setSharedVolume(nextVolume);
}
onVolumeChange(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.isMuted.set(audio.muted || audio.volume === 0);
if (!audio.muted && audio.volume > 0) {
const volume = Math.round(audio.volume * 100);
this.volumePercent.set(volume);
this.lastNonZeroVolume.set(volume);
this.setSharedVolume(volume);
}
}
toggleMute(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
if (audio.muted || audio.volume === 0) {
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
audio.muted = false;
audio.volume = restoredVolume / 100;
this.volumePercent.set(restoredVolume);
this.isMuted.set(false);
this.setSharedVolume(restoredVolume);
return;
}
audio.muted = true;
this.isMuted.set(true);
}
requestDownload(): void {
this.downloadRequested.emit(undefined);
}
private async ensureWaveformLoaded(): Promise<void> {
if (this.waveformLoading() || this.waveSurfer)
return;
const source = this.src();
const audio = this.audioRef?.nativeElement;
const waveformContainer = this.waveformContainer?.nativeElement;
if (!source || !audio || !waveformContainer)
return;
this.waveformLoading.set(true);
this.waveformUnavailable.set(false);
try {
await this.ensureAudioMetadata(audio);
await this.waitForNextPaint();
if (!this.waveformExpanded()) {
this.waveformLoading.set(false);
return;
}
const { default: WaveSurfer } = await import('wavesurfer.js');
this.waveSurfer = WaveSurfer.create({
barGap: 2,
barRadius: 999,
barWidth: 3,
container: waveformContainer,
cursorWidth: 0,
dragToSeek: true,
height: 56,
hideScrollbar: true,
interact: true,
media: audio,
normalize: true,
progressColor: this.resolveThemeColor('--primary', 'hsl(262 83% 58%)', 0.9),
waveColor: this.resolveThemeColor('--foreground', 'hsl(215 16% 47%)', 0.22)
});
this.waveSurfer.on('error', () => {
this.waveformLoading.set(false);
this.waveformUnavailable.set(true);
this.destroyWaveSurfer();
});
this.waveSurfer.on('ready', () => {
this.waveformLoading.set(false);
this.waveformUnavailable.set(false);
});
} catch {
this.destroyWaveSurfer();
this.waveformLoading.set(false);
this.waveformUnavailable.set(true);
}
}
private ensureAudioMetadata(audio: HTMLAudioElement): Promise<void> {
if (audio.readyState >= HTMLMediaElement.HAVE_METADATA)
return Promise.resolve();
return new Promise((resolve, reject) => {
const handleLoadedMetadata = (): void => {
cleanup();
resolve();
};
const handleCanPlay = (): void => {
cleanup();
resolve();
};
const handleError = (): void => {
cleanup();
reject(new Error('Failed to load audio metadata'));
};
const cleanup = (): void => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('canplay', handleCanPlay);
audio.removeEventListener('error', handleError);
};
audio.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true });
audio.addEventListener('canplay', handleCanPlay, { once: true });
audio.addEventListener('error', handleError, { once: true });
audio.load();
});
}
private destroyWaveSurfer(): void {
if (!this.waveSurfer)
return;
this.waveSurfer.destroy();
this.waveSurfer = null;
}
private resolveThemeColor(cssVarName: string, fallback: string, alpha: number): string {
if (typeof window === 'undefined')
return fallback;
const rawValue = window.getComputedStyle(document.documentElement)
.getPropertyValue(cssVarName)
.trim();
if (!rawValue)
return fallback;
return `hsl(${rawValue} / ${alpha})`;
}
private applyAudioVolume(volumePercent: number): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
audio.volume = this.isMuted() ? 0 : volumePercent / 100;
audio.muted = this.isMuted();
}
private setSharedVolume(volumePercent: number): void {
persistSharedAudioPlayerVolume(volumePercent);
}
private buildSliderBackground(fillPercent: number): string {
return [
'linear-gradient(90deg, ',
'hsl(var(--primary)) 0%, ',
`hsl(var(--primary)) ${fillPercent}%, `,
`hsl(var(--secondary)) ${fillPercent}%, `,
'hsl(var(--secondary)) 100%)'
].join('');
}
private waitForNextPaint(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0)
return '0:00';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,160 @@
<div
#playerRoot
class="video-player-shell"
[class.fullscreen]="isFullscreen()"
[class.controls-hidden]="isFullscreen() && !controlsVisible()"
(mousemove)="onPlayerMouseMove()"
>
@if (!isFullscreen()) {
<div class="video-top-bar">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
@if (sizeLabel()) {
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
}
</div>
<button
type="button"
(click)="requestDownload()"
class="video-control-btn"
title="Save to folder"
aria-label="Save video to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
}
<div
class="video-stage"
(click)="onVideoClick()"
(dblclick)="onVideoDoubleClick($event)"
(keydown.enter)="onVideoClick()"
(keydown.space)="onVideoClick(); $event.preventDefault()"
role="button"
tabindex="0"
aria-label="Toggle video playback"
>
<video
#videoEl
[src]="src()"
playsinline
preload="metadata"
class="chat-video-element"
(ended)="onPause()"
(loadedmetadata)="onLoadedMetadata()"
(pause)="onPause()"
(play)="onPlay()"
(timeupdate)="onTimeUpdate()"
(volumechange)="onVolumeChange()"
></video>
@if (!isPlaying()) {
<button
type="button"
(click)="onOverlayPlayClick($event)"
class="video-play-overlay"
title="Play video"
aria-label="Play video"
>
<ng-icon
name="lucidePlay"
class="w-8 h-8"
/>
</button>
}
</div>
<div
class="video-bottom-bar"
[class.fullscreen-overlay]="isFullscreen()"
[class.hidden-overlay]="isFullscreen() && !controlsVisible()"
>
<input
type="range"
min="0"
[max]="durationSeconds() || 0"
[value]="currentTimeSeconds()"
(input)="onSeek($event)"
class="seek-slider"
[style.background]="seekTrackBackground()"
aria-label="Seek video"
/>
<div class="video-controls-row">
<div class="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
(click)="togglePlayback()"
class="video-control-btn"
[title]="isPlaying() ? 'Pause' : 'Play'"
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
>
<ng-icon
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
class="w-4 h-4"
/>
</button>
<span class="video-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
</div>
<div class="video-volume-group">
<button
type="button"
(click)="toggleMute()"
class="video-control-btn"
[title]="isMuted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
>
<ng-icon
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="w-4 h-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="isMuted() ? 0 : volumePercent()"
(input)="onVolumeInput($event)"
class="volume-slider"
[style.background]="volumeTrackBackground()"
aria-label="Video volume"
/>
</div>
@if (isFullscreen()) {
<button
type="button"
(click)="requestDownload()"
class="video-control-btn"
title="Save to folder"
aria-label="Save video to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
}
<button
type="button"
(click)="toggleFullscreen()"
class="video-control-btn"
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
>
<ng-icon
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
class="w-4 h-4"
/>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,243 @@
:host {
display: block;
max-width: 40rem;
}
.video-player-shell {
position: relative;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
background:
radial-gradient(circle at top, hsl(var(--primary) / 0.16), transparent 38%),
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(222deg 47% 8%) 100%);
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
.video-player-shell.fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
border: none;
border-radius: 0;
background: rgb(0 0 0);
}
.video-top-bar,
.video-bottom-bar {
position: relative;
z-index: 2;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.video-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 0.875rem 0.625rem;
background: linear-gradient(180deg, rgb(6 10 18 / 82%) 0%, rgb(6 10 18 / 30%) 100%);
}
.video-stage {
position: relative;
background: rgb(0 0 0 / 40%);
}
.video-player-shell.fullscreen .video-stage {
height: 100vh;
background: rgb(0 0 0);
}
.video-player-shell.fullscreen.controls-hidden .video-stage,
.video-player-shell.fullscreen.controls-hidden .chat-video-element {
cursor: none;
}
.chat-video-element {
display: block;
width: 100%;
max-height: min(28rem, 70vh);
background: rgb(0 0 0 / 85%);
cursor: pointer;
object-fit: contain;
}
.video-player-shell.fullscreen .chat-video-element {
width: 100vw;
height: 100vh;
max-height: 100vh;
}
.video-play-overlay {
position: absolute;
left: 50%;
top: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--primary-foreground));
background: rgb(8 14 24 / 78%);
box-shadow: 0 12px 24px rgb(0 0 0 / 35%);
transition:
transform 0.16s ease,
background-color 0.16s ease;
}
.video-play-overlay:hover {
transform: translate(-50%, -50%) scale(1.05);
background: rgb(12 18 30 / 88%);
}
.video-bottom-bar {
padding: 0.75rem 0.875rem 0.875rem;
background: linear-gradient(180deg, rgb(6 10 18 / 38%) 0%, rgb(6 10 18 / 86%) 100%);
}
.video-bottom-bar.fullscreen-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
padding-bottom: max(0.875rem, env(safe-area-inset-bottom));
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.video-bottom-bar.hidden-overlay {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.video-controls-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.625rem;
}
.video-control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--foreground));
background: hsl(var(--card) / 0.72);
transition:
background-color 0.16s ease,
border-color 0.16s ease,
transform 0.16s ease;
}
.video-control-btn:hover {
border-color: hsl(var(--primary) / 0.75);
background: hsl(var(--primary) / 0.14);
transform: translateY(-1px);
}
.video-time-label {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.video-volume-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.seek-slider,
.volume-slider {
-webkit-appearance: none;
appearance: none;
outline: none;
cursor: pointer;
}
.seek-slider {
width: 100%;
height: 6px;
border-radius: 9999px;
background: linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--primary)) var(--value, 0%), hsl(var(--secondary)) var(--value, 0%), hsl(var(--secondary)) 100%);
}
.volume-slider {
width: 5.5rem;
height: 6px;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-webkit-slider-runnable-track,
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 9999px;
background: transparent;
}
.seek-slider::-webkit-slider-thumb,
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.seek-slider::-moz-range-track,
.volume-slider::-moz-range-track {
height: 6px;
border: none;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-moz-range-thumb,
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
@media (width <= 640px) {
.video-top-bar {
padding-inline: 0.75rem;
}
.video-bottom-bar {
padding-inline: 0.75rem;
}
.video-controls-row {
gap: 0.5rem;
}
.video-volume-group {
display: none;
}
.video-time-label {
font-size: 0.6875rem;
}
}

View File

@@ -0,0 +1,327 @@
import {
Component,
ElementRef,
HostListener,
OnDestroy,
ViewChild,
computed,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDownload,
lucideMaximize,
lucideMinimize,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
@Component({
selector: 'app-chat-video-player',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDownload,
lucideMaximize,
lucideMinimize,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './chat-video-player.component.html',
styleUrl: './chat-video-player.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ChatVideoPlayerComponent implements OnDestroy {
src = input.required<string>();
filename = input.required<string>();
sizeLabel = input<string>('');
downloadRequested = output<undefined>();
private readonly SINGLE_CLICK_DELAY_MS = 300;
private readonly FULLSCREEN_IDLE_MS = 2200;
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
isPlaying = signal(false);
isMuted = signal(false);
isFullscreen = signal(false);
controlsVisible = signal(true);
currentTimeSeconds = signal(0);
durationSeconds = signal(0);
volumePercent = signal(100);
private lastNonZeroVolume = signal(100);
private singleClickTimer: ReturnType<typeof setTimeout> | null = null;
private controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
progressPercent = computed(() => {
const duration = this.durationSeconds();
if (duration <= 0)
return 0;
return (this.currentTimeSeconds() / duration) * 100;
});
seekTrackBackground = computed(() => {
const progress = Math.max(0, Math.min(100, this.progressPercent()));
return this.buildSliderBackground(progress);
});
volumeTrackBackground = computed(() => {
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
return this.buildSliderBackground(volume);
});
@HostListener('document:fullscreenchange')
onFullscreenChange(): void {
const player = this.playerRoot?.nativeElement;
const isFullscreen = !!player && document.fullscreenElement === player;
this.isFullscreen.set(isFullscreen);
if (isFullscreen) {
this.revealControlsTemporarily();
return;
}
this.controlsVisible.set(true);
this.clearControlsHideTimer();
}
ngOnDestroy(): void {
this.clearControlsHideTimer();
this.clearSingleClickTimer();
}
onPlayerMouseMove(): void {
if (!this.isFullscreen())
return;
this.revealControlsTemporarily();
}
onVideoClick(): void {
this.clearSingleClickTimer();
this.revealControlsTemporarily();
this.singleClickTimer = setTimeout(() => {
this.singleClickTimer = null;
this.togglePlayback();
}, this.SINGLE_CLICK_DELAY_MS);
}
onVideoDoubleClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.clearSingleClickTimer();
void this.toggleFullscreen();
}
onOverlayPlayClick(event: MouseEvent): void {
event.stopPropagation();
this.togglePlayback();
}
togglePlayback(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
if (video.paused || video.ended) {
void video.play().catch(() => {
this.isPlaying.set(false);
});
return;
}
video.pause();
this.revealControlsTemporarily();
}
onLoadedMetadata(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.durationSeconds.set(Number.isFinite(video.duration) ? video.duration : 0);
this.currentTimeSeconds.set(video.currentTime || 0);
}
onTimeUpdate(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.currentTimeSeconds.set(video.currentTime || 0);
}
onPlay(): void {
this.isPlaying.set(true);
this.revealControlsTemporarily();
}
onPause(): void {
this.isPlaying.set(false);
this.revealControlsTemporarily();
}
onSeek(event: Event): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
const nextTime = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(nextTime))
return;
video.currentTime = nextTime;
this.currentTimeSeconds.set(nextTime);
this.revealControlsTemporarily();
}
onVolumeInput(event: Event): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
video.volume = nextVolume / 100;
video.muted = nextVolume === 0;
if (nextVolume > 0)
this.lastNonZeroVolume.set(nextVolume);
this.volumePercent.set(nextVolume);
this.isMuted.set(video.muted);
this.revealControlsTemporarily();
}
onVolumeChange(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.isMuted.set(video.muted || video.volume === 0);
if (!video.muted && video.volume > 0) {
const volume = Math.round(video.volume * 100);
this.volumePercent.set(volume);
this.lastNonZeroVolume.set(volume);
}
this.revealControlsTemporarily();
}
toggleMute(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
if (video.muted || video.volume === 0) {
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
video.muted = false;
video.volume = restoredVolume / 100;
this.volumePercent.set(restoredVolume);
this.isMuted.set(false);
this.revealControlsTemporarily();
return;
}
video.muted = true;
this.isMuted.set(true);
this.revealControlsTemporarily();
}
async toggleFullscreen(): Promise<void> {
const player = this.playerRoot?.nativeElement;
if (!player)
return;
if (document.fullscreenElement === player) {
await document.exitFullscreen().catch(() => {});
return;
}
await player.requestFullscreen?.().catch(() => {});
}
requestDownload(): void {
this.downloadRequested.emit(undefined);
this.revealControlsTemporarily();
}
private buildSliderBackground(fillPercent: number): string {
return [
'linear-gradient(90deg, ',
'hsl(var(--primary)) 0%, ',
`hsl(var(--primary)) ${fillPercent}%, `,
`hsl(var(--secondary)) ${fillPercent}%, `,
'hsl(var(--secondary)) 100%)'
].join('');
}
private revealControlsTemporarily(): void {
if (!this.isFullscreen()) {
this.controlsVisible.set(true);
return;
}
this.controlsVisible.set(true);
this.clearControlsHideTimer();
this.controlsHideTimer = setTimeout(() => {
this.controlsVisible.set(false);
}, this.FULLSCREEN_IDLE_MS);
}
private clearControlsHideTimer(): void {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
}
private clearSingleClickTimer(): void {
if (this.singleClickTimer) {
clearTimeout(this.singleClickTimer);
this.singleClickTimer = null;
}
}
formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0)
return '0:00';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,45 @@
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog -->
<div
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()"
>
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content />
</div>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import {
Component,
input,
output,
HostListener
} from '@angular/core';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
templateUrl: './confirm-dialog.component.html',
host: {
style: 'display: contents;'
}
})
export class ConfirmDialogComponent {
title = input.required<string>();
confirmLabel = input<string>('Confirm');
cancelLabel = input<string>('Cancel');
variant = input<'primary' | 'danger'>('primary');
widthClass = input<string>('w-[320px]');
confirmed = output<undefined>();
cancelled = output<undefined>();
@HostListener('document:keydown.escape')
onEscape(): void {
this.cancelled.emit(undefined);
}
}

View File

@@ -0,0 +1,22 @@
<!-- Invisible backdrop that captures clicks outside -->
<div
class="fixed inset-0 z-40"
(click)="closed.emit(undefined)"
(contextmenu)="$event.preventDefault(); closed.emit(undefined)"
(keydown.enter)="closed.emit(undefined)"
(keydown.space)="closed.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Positioned menu panel -->
<div
#panel
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="widthPx() ? '' : width()"
[style.left.px]="clampedX()"
[style.top.px]="clampedY()"
[style.width.px]="widthPx() || null"
>
<ng-content />
</div>

View File

@@ -0,0 +1,28 @@
:host {
display: contents;
}
/* Convenience classes consumers can use on projected buttons */
:host ::ng-deep .context-menu-item {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
}
:host ::ng-deep .context-menu-item-danger {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
}
:host ::ng-deep .context-menu-item-icon {
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
}
:host ::ng-deep .context-menu-item-icon-danger {
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
}
:host ::ng-deep .context-menu-divider {
@apply border-t border-border my-1;
}
:host ::ng-deep .context-menu-empty {
@apply px-3 py-1.5 text-sm text-muted-foreground;
}

View File

@@ -0,0 +1,75 @@
import {
Component,
input,
output,
signal,
HostListener,
ViewChild,
ElementRef,
AfterViewInit,
OnInit
} from '@angular/core';
@Component({
selector: 'app-context-menu',
standalone: true,
templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ContextMenuComponent implements OnInit, AfterViewInit {
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>();
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>();
width = input<string>('w-48');
widthPx = input<number | null>(null);
closed = output<undefined>();
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
clampedX = signal(0);
clampedY = signal(0);
ngOnInit(): void {
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
this.clampedY.set(this.clampY(this.y(), 80));
}
ngAfterViewInit(): void {
const rect = this.panelRef.nativeElement.getBoundingClientRect();
this.clampedX.set(this.clampX(this.x(), rect.width));
this.clampedY.set(this.clampY(this.y(), rect.height));
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.closed.emit(undefined);
}
private estimateWidth(): number {
const px = this.widthPx();
if (px)
return px;
const match = this.width().match(/w-(\d+)/);
return match ? parseInt(match[1], 10) * 4 : 192;
}
private clampX(rawX: number, panelWidth: number): number {
const margin = 8;
const maxX = window.innerWidth - panelWidth - margin;
return Math.max(margin, Math.min(rawX, maxX));
}
private clampY(rawY: number, panelHeight: number): number {
const margin = 8;
const maxY = window.innerHeight - panelHeight - margin;
return Math.max(margin, Math.min(rawY, maxY));
}
}

View File

@@ -0,0 +1,75 @@
<div
#viewport
class="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-background/50"
>
@if (entries().length === 0) {
<div class="flex h-full min-h-56 items-center justify-center px-6 py-10 text-center">
<div>
<p class="text-sm font-medium text-foreground">No logs match the current filters.</p>
<p class="mt-1 text-xs text-muted-foreground">Generate activity in the app or loosen the filters to see captured events.</p>
</div>
</div>
} @else {
<div class="divide-y divide-border/70">
@for (entry of entries(); track entry.id) {
<article
class="px-4 py-3 transition-colors"
[class]="getRowClass(entry.level)"
>
<button
type="button"
class="w-full text-left disabled:cursor-default"
[disabled]="!entry.payloadText"
(click)="toggleExpanded(entry.id)"
>
<div class="flex items-start gap-3">
<span
class="min-w-[88px] pt-0.5 text-[11px] font-mono text-muted-foreground"
[title]="entry.dateTimeLabel"
>
{{ entry.timeLabel }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span [class]="getBadgeClass(entry.level)">{{ getLevelLabel(entry.level) }}</span>
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{{ entry.source }}
</span>
@if (entry.count > 1) {
<span class="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
x{{ entry.count }}
</span>
}
</div>
<p class="mt-2 break-words text-sm text-foreground">{{ entry.message }}</p>
@if (entry.payloadText) {
<p class="mt-2 text-xs text-muted-foreground">
{{ isExpanded(entry.id) ? 'Hide details' : 'Show details' }}
</p>
}
</div>
@if (entry.payloadText) {
<span class="pt-1 text-muted-foreground">
<ng-icon
[name]="isExpanded(entry.id) ? 'lucideChevronDown' : 'lucideChevronRight'"
class="h-4 w-4"
/>
</span>
}
</div>
</button>
@if (entry.payloadText && isExpanded(entry.id)) {
<pre class="mt-3 overflow-x-auto rounded-lg bg-background px-3 py-3 text-[11px] leading-5 text-muted-foreground">{{
entry.payloadText
}}</pre>
}
</article>
}
</div>
}
</div>

View File

@@ -0,0 +1,108 @@
import {
Component,
ElementRef,
effect,
input,
signal,
viewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown, lucideChevronRight } from '@ng-icons/lucide';
import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/services/debugging.service';
@Component({
selector: 'app-debug-console-entry-list',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideChevronDown,
lucideChevronRight
})
],
templateUrl: './debug-console-entry-list.component.html',
host: {
style: 'display: flex; min-height: 0; overflow: hidden;'
}
})
export class DebugConsoleEntryListComponent {
readonly entries = input.required<DebugLogEntry[]>();
readonly autoScroll = input.required<boolean>();
readonly expandedEntryIds = signal<number[]>([]);
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('viewport');
constructor() {
effect(() => {
this.entries();
if (!this.autoScroll())
return;
requestAnimationFrame(() => this.scrollToBottom());
});
}
toggleExpanded(entryId: number): void {
const nextExpandedIds = new Set(this.expandedEntryIds());
if (nextExpandedIds.has(entryId)) {
nextExpandedIds.delete(entryId);
} else {
nextExpandedIds.add(entryId);
}
this.expandedEntryIds.set(Array.from(nextExpandedIds));
}
isExpanded(entryId: number): boolean {
return this.expandedEntryIds().includes(entryId);
}
getRowClass(level: DebugLogLevel): string {
switch (level) {
case 'event':
return 'bg-transparent hover:bg-secondary/20';
case 'info':
return 'bg-sky-500/[0.04] hover:bg-sky-500/[0.08]';
case 'warn':
return 'bg-yellow-500/[0.05] hover:bg-yellow-500/[0.08]';
case 'error':
return 'bg-destructive/[0.05] hover:bg-destructive/[0.08]';
case 'debug':
return 'bg-fuchsia-500/[0.05] hover:bg-fuchsia-500/[0.08]';
}
}
getBadgeClass(level: DebugLogLevel): string {
const base = 'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide';
switch (level) {
case 'event':
return base + ' bg-primary/10 text-primary';
case 'info':
return base + ' bg-sky-500/10 text-sky-300';
case 'warn':
return base + ' bg-yellow-500/10 text-yellow-300';
case 'error':
return base + ' bg-destructive/10 text-destructive';
case 'debug':
return base + ' bg-fuchsia-500/10 text-fuchsia-300';
}
}
getLevelLabel(level: DebugLogLevel): string {
return level.toUpperCase();
}
private scrollToBottom(): void {
const viewport = this.viewportRef()?.nativeElement;
if (!viewport)
return;
viewport.scrollTop = viewport.scrollHeight;
}
}

View File

@@ -0,0 +1,247 @@
<div class="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_20rem] overflow-hidden bg-background/50">
<section class="relative min-h-0 border-r border-border bg-background/70">
<div
#graph
class="h-full min-h-[22rem] w-full"
></div>
<div class="pointer-events-none absolute left-3 top-3 rounded-xl border border-border/80 bg-card/90 px-3 py-2 shadow-xl backdrop-blur-sm">
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-foreground">
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.clientCount }} clients</span>
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.serverCount }} servers</span>
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.peerConnectionCount }} peer links</span>
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.messageCount }} grouped messages</span>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[10px] text-muted-foreground">
<span class="rounded-full border border-blue-400/40 bg-blue-500/10 px-2 py-0.5 text-blue-200">Local client</span>
<span class="rounded-full border border-emerald-400/40 bg-emerald-500/10 px-2 py-0.5 text-emerald-200">Remote client</span>
<span class="rounded-full border border-orange-400/40 bg-orange-500/10 px-2 py-0.5 text-orange-200">Signaling</span>
<span class="rounded-full border border-violet-400/40 bg-violet-500/10 px-2 py-0.5 text-violet-200">Server</span>
</div>
</div>
@if (snapshot().edges.length === 0) {
<div class="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center">
<div class="max-w-sm rounded-2xl border border-border/80 bg-card/85 px-5 py-4 shadow-xl backdrop-blur-sm">
<p class="text-sm font-semibold text-foreground">No network activity captured yet.</p>
<p class="mt-1 text-xs text-muted-foreground">
Enable debugging before connecting to signaling, joining a server, or opening peer channels to populate the live map.
</p>
</div>
</div>
}
</section>
<aside class="min-h-0 overflow-y-auto bg-card/60">
<div class="space-y-4 p-4">
<section>
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-foreground">Peer details</h3>
<span class="text-[11px] text-muted-foreground">Updated {{ formatAge(snapshot().generatedAt) }}</span>
</div>
@if (statusNodes().length === 0) {
<p class="mt-2 text-xs text-muted-foreground">
Connected clients appear here with IDs, handshakes, text counts, streams, drops, and live download metrics.
</p>
} @else {
<div class="mt-3 space-y-2">
@for (node of statusNodes(); track node.id) {
<article class="rounded-xl border border-border/80 bg-background/70 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground">{{ node.label }}</p>
<p class="truncate text-[11px] text-muted-foreground">{{ node.secondaryLabel }}</p>
<p class="mt-1 break-all text-[10px] text-muted-foreground/90">ID {{ formatClientId(node) }}</p>
@if (formatPeerIdentity(node); as peerIdentity) {
<p class="mt-0.5 break-all text-[10px] text-muted-foreground/80">Peer {{ peerIdentity }}</p>
}
</div>
<span [class]="getStatusBadgeClass(node)">
{{ getNodeActivityLabel(node) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
@for (status of node.statuses; track status) {
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] text-muted-foreground">{{ status }}</span>
}
</div>
<div class="mt-3 grid grid-cols-1 gap-2 text-[11px] text-muted-foreground sm:grid-cols-2">
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
<p class="font-medium text-foreground/90">Streams</p>
<p
class="mt-1"
title="A = audio streams, V = video streams"
>
Streams
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Audio streams"
>A</span
>{{ node.streams.audio }}
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Video streams"
>V</span
>{{ node.streams.video }}
</p>
</div>
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
<p class="font-medium text-foreground/90">Text</p>
<p
class="mt-1"
title="Up arrow = sent messages, down arrow = received messages"
>
Text
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Sent messages"
></span
>{{ node.textMessages.sent }}
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Received messages"
></span
>{{ node.textMessages.received }}
</p>
</div>
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2 sm:col-span-2">
<p class="font-medium text-foreground/90">Handshakes</p>
<p
class="mt-1"
title="Counts are shown as sent / received"
>
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="WebRTC offers"
>O</span
>
{{ node.handshake.offersSent }}/{{ node.handshake.offersReceived }}
·
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="WebRTC answers"
>A</span
>
{{ node.handshake.answersSent }}/{{ node.handshake.answersReceived }}
·
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="ICE candidates"
>ICE</span
>
{{ node.handshake.iceSent }}/{{ node.handshake.iceReceived }}
</p>
</div>
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2 sm:col-span-2">
<p class="font-medium text-foreground/90">Download Mbps</p>
<p
class="mt-1"
title="Down arrow = download rate. F = file, A = audio, V = video."
>
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Download rate"
></span
>
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="File download Mbps"
>F</span
>
{{ formatMbps(node.downloads.fileMbps) }}
·
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Audio download Mbps"
>A</span
>
{{ formatMbps(node.downloads.audioMbps) }}
·
<span
class="cursor-help underline decoration-dotted underline-offset-2"
title="Video download Mbps"
>V</span
>
{{ formatMbps(node.downloads.videoMbps) }}
</p>
</div>
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
<p class="font-medium text-foreground/90">Ping</p>
<p class="mt-1">{{ node.pingMs !== null ? node.pingMs + ' ms' : '-' }}</p>
</div>
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
<p class="font-medium text-foreground/90">Connection drops</p>
<p class="mt-1">{{ node.connectionDrops }}</p>
</div>
</div>
</article>
}
</div>
}
</section>
<section>
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-foreground">Connection flows</h3>
<span class="text-[11px] text-muted-foreground">Grouped by edge + message type</span>
</div>
@if (connectionEdges().length === 0) {
<p class="mt-2 text-xs text-muted-foreground">Once logs arrive, each edge will show grouped signaling or P2P message types with counts.</p>
} @else {
<div class="mt-3 space-y-3">
@for (edge of connectionEdges(); track edge.id) {
<article class="rounded-xl border border-border/80 bg-background/70 px-3 py-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground">{{ formatEdgeHeading(edge) }}</p>
<p class="mt-0.5 text-[11px] text-muted-foreground">{{ edge.stateLabel }} · {{ formatAge(edge.lastSeen) }}</p>
</div>
<span [class]="getConnectionBadgeClass(edge)">
{{ getEdgeKindLabel(edge) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
@if (edge.pingMs !== null) {
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">Ping {{ edge.pingMs }} ms</span>
}
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ edge.messageTotal }} grouped messages</span>
</div>
@if (edge.messageGroups.length > 0) {
<div class="mt-3 flex flex-wrap gap-1.5">
@for (group of getVisibleMessageGroups(edge); track group.id) {
<span [class]="getMessageBadgeClass(group)">{{ formatMessageGroup(group) }}</span>
}
<span
class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] text-muted-foreground"
[class.hidden]="getHiddenMessageGroupCount(edge) === 0"
>+{{ getHiddenMessageGroupCount(edge) }} more</span
>
</div>
} @else {
<p class="mt-3 text-[11px] text-muted-foreground">No grouped messages on this edge yet.</p>
}
</article>
}
</div>
}
</section>
</div>
</aside>
</div>

View File

@@ -0,0 +1,651 @@
/* eslint-disable id-denylist, id-length, padding-line-between-statements */
import {
Component,
ElementRef,
OnDestroy,
computed,
effect,
input,
viewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import cytoscape, { type Core, type ElementDefinition } from 'cytoscape';
import {
type DebugNetworkEdge,
type DebugNetworkMessageGroup,
type DebugNetworkNode,
type DebugNetworkSnapshot
} from '../../../../core/services/debugging.service';
@Component({
selector: 'app-debug-console-network-map',
standalone: true,
imports: [CommonModule],
templateUrl: './debug-console-network-map.component.html',
host: {
style: 'display: flex; min-height: 0; overflow: hidden;'
}
})
export class DebugConsoleNetworkMapComponent implements OnDestroy {
readonly snapshot = input.required<DebugNetworkSnapshot>();
readonly graphRef = viewChild<ElementRef<HTMLDivElement>>('graph');
readonly statusNodes = computed(() => {
const clientNodes = this.snapshot().nodes
.filter((node) => node.kind === 'local-client' || node.kind === 'remote-client');
const remoteNodes = clientNodes.filter((node) => node.kind === 'remote-client');
const visibleNodes = remoteNodes.length > 0
? remoteNodes
: clientNodes;
return visibleNodes
.sort((nodeA, nodeB) => {
if (nodeA.isActive !== nodeB.isActive)
return nodeA.isActive ? -1 : 1;
return nodeA.label.localeCompare(nodeB.label);
});
});
readonly connectionEdges = computed(() => {
return [...this.snapshot().edges].sort((edgeA, edgeB) => {
if (edgeA.isActive !== edgeB.isActive)
return edgeA.isActive ? -1 : 1;
if (edgeA.kind !== edgeB.kind)
return this.getEdgeOrder(edgeA.kind) - this.getEdgeOrder(edgeB.kind);
return edgeB.lastSeen - edgeA.lastSeen;
});
});
private cytoscapeInstance: Core | null = null;
private resizeObserver: ResizeObserver | null = null;
private lastStructureKey = '';
constructor() {
effect(() => {
const container = this.graphRef()?.nativeElement;
const snapshot = this.snapshot();
if (!container)
return;
this.ensureGraph(container);
requestAnimationFrame(() => this.renderGraph(snapshot));
});
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.cytoscapeInstance?.destroy();
this.cytoscapeInstance = null;
}
formatAge(timestamp: number): string {
const deltaMs = Math.max(0, Date.now() - timestamp);
if (deltaMs < 1_000)
return 'just now';
const seconds = Math.floor(deltaMs / 1_000);
if (seconds < 60)
return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
formatEdgeHeading(edge: DebugNetworkEdge): string {
return `${edge.sourceLabel}${edge.targetLabel}`;
}
formatMessageGroup(group: DebugNetworkMessageGroup): string {
const direction = group.direction === 'outbound' ? '↑' : '↓';
return `${direction} ${group.type} ×${group.count}`;
}
formatHandshakeSummary(node: DebugNetworkNode): string {
const offerSummary = `${node.handshake.offersSent}/${node.handshake.offersReceived}`;
const answerSummary = `${node.handshake.answersSent}/${node.handshake.answersReceived}`;
const iceSummary = `${node.handshake.iceSent}/${node.handshake.iceReceived}`;
return `O ${offerSummary} · A ${answerSummary} · ICE ${iceSummary}`;
}
formatTextSummary(node: DebugNetworkNode): string {
return `Text ↑${node.textMessages.sent}${node.textMessages.received}`;
}
formatStreamSummary(node: DebugNetworkNode): string {
return `Streams A${node.streams.audio} V${node.streams.video}`;
}
formatDownloadSummary(node: DebugNetworkNode): string {
const metrics = [
`F ${this.formatMbps(node.downloads.fileMbps)}`,
`A ${this.formatMbps(node.downloads.audioMbps)}`,
`V ${this.formatMbps(node.downloads.videoMbps)}`
];
return `${metrics.join(' · ')}`;
}
formatClientId(node: DebugNetworkNode): string {
return node.userId ?? node.identity ?? 'Unavailable';
}
formatPeerIdentity(node: DebugNetworkNode): string | null {
if (!node.identity || node.identity === node.userId)
return null;
return node.identity;
}
hasDownloadMetrics(node: DebugNetworkNode): boolean {
return node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null;
}
formatMbps(value: number | null): string {
if (value === null)
return '-';
return value >= 10 ? value.toFixed(1) : value.toFixed(2);
}
getConnectionBadgeClass(edge: DebugNetworkEdge): string {
const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium';
if (!edge.isActive)
return base + ' border-border text-muted-foreground';
switch (edge.kind) {
case 'signaling':
return base + ' border-orange-400/40 bg-orange-500/10 text-orange-300';
case 'peer':
return base + ' border-emerald-400/40 bg-emerald-500/10 text-emerald-300';
case 'membership':
return base + ' border-violet-400/40 bg-violet-500/10 text-violet-300';
}
}
getMessageBadgeClass(group: DebugNetworkMessageGroup): string {
const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium';
if (group.scope === 'signaling')
return base + ' border-orange-400/30 bg-orange-500/10 text-orange-200';
if (group.direction === 'outbound')
return base + ' border-sky-400/30 bg-sky-500/10 text-sky-200';
return base + ' border-cyan-400/30 bg-cyan-500/10 text-cyan-200';
}
getStatusBadgeClass(node: DebugNetworkNode): string {
const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium';
if (node.isSpeaking)
return base + ' border-emerald-400/40 bg-emerald-500/10 text-emerald-300';
if (node.isTyping)
return base + ' border-amber-400/40 bg-amber-500/10 text-amber-300';
if (node.isStreaming)
return base + ' border-fuchsia-400/40 bg-fuchsia-500/10 text-fuchsia-300';
if (node.isMuted)
return base + ' border-rose-400/40 bg-rose-500/10 text-rose-300';
return base + ' border-border text-muted-foreground';
}
getNodeActivityLabel(node: DebugNetworkNode): string {
if (node.isSpeaking)
return 'Speaking';
if (node.isTyping)
return 'Typing';
if (node.isStreaming)
return 'Streaming';
if (node.isMuted)
return 'Muted';
return 'Active';
}
getEdgeKindLabel(edge: DebugNetworkEdge): string {
switch (edge.kind) {
case 'membership':
return 'Membership';
case 'signaling':
return 'Signaling';
case 'peer':
return 'Peer';
}
}
getVisibleMessageGroups(edge: DebugNetworkEdge): DebugNetworkMessageGroup[] {
return edge.messageGroups.slice(0, 8);
}
getHiddenMessageGroupCount(edge: DebugNetworkEdge): number {
return Math.max(0, edge.messageGroups.length - 8);
}
private ensureGraph(container: HTMLDivElement): void {
if (this.cytoscapeInstance)
return;
this.cytoscapeInstance = cytoscape({
container,
boxSelectionEnabled: false,
minZoom: 0.45,
maxZoom: 1.8,
wheelSensitivity: 0.2,
autoungrabify: true,
style: this.buildGraphStyles() as never
});
this.resizeObserver = new ResizeObserver(() => {
this.cytoscapeInstance?.resize();
});
this.resizeObserver.observe(container);
}
private renderGraph(snapshot: DebugNetworkSnapshot): void {
if (!this.cytoscapeInstance)
return;
const structureKey = this.buildStructureKey(snapshot);
const elements = this.buildGraphElements(snapshot);
this.cytoscapeInstance.elements().remove();
this.cytoscapeInstance.add(elements);
this.cytoscapeInstance.resize();
if (structureKey !== this.lastStructureKey) {
this.cytoscapeInstance.fit(undefined, 48);
this.lastStructureKey = structureKey;
}
}
private buildStructureKey(snapshot: DebugNetworkSnapshot): string {
return JSON.stringify({
edgeIds: snapshot.edges.map((edge) => edge.id),
nodeIds: snapshot.nodes.map((node) => node.id)
});
}
private buildGraphElements(snapshot: DebugNetworkSnapshot): ElementDefinition[] {
const positions = this.buildNodePositions(snapshot.nodes);
return [
...snapshot.nodes.map((node) => ({
data: {
id: node.id,
label: this.buildNodeLabel(node)
},
position: positions.get(node.id) ?? { x: 0,
y: 0 },
classes: this.buildNodeClasses(node)
})),
...snapshot.edges.map((edge) => {
const label = this.buildEdgeLabel(edge);
return {
data: {
id: edge.id,
source: edge.sourceId,
target: edge.targetId,
label
},
classes: this.buildEdgeClasses(edge, label)
};
})
];
}
private buildNodePositions(nodes: DebugNetworkNode[]): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
const localNodes = nodes.filter((node) => node.kind === 'local-client');
const signalingNodes = nodes.filter((node) => node.kind === 'signaling-server');
const serverNodes = nodes.filter((node) => node.kind === 'app-server');
const remoteNodes = nodes.filter((node) => node.kind === 'remote-client');
for (const node of localNodes) {
positions.set(node.id, {
x: 140,
y: 320
});
}
this.applyColumnPositions(positions, signalingNodes, 470, 170, 112);
this.applyColumnPositions(positions, serverNodes, 470, 470, 112);
this.applyColumnPositions(positions, remoteNodes, 820, 320, 186);
return positions;
}
private applyColumnPositions(
positions: Map<string, { x: number; y: number }>,
nodes: DebugNetworkNode[],
x: number,
centerY: number,
spacing: number
): void {
if (nodes.length === 0)
return;
const totalHeight = (nodes.length - 1) * spacing;
const startY = centerY - totalHeight / 2;
nodes.forEach((node, index) => {
positions.set(node.id, {
x,
y: startY + index * spacing
});
});
}
private buildNodeClasses(node: DebugNetworkNode): string {
const classes: string[] = [node.kind];
if (!node.isActive)
classes.push('inactive');
if (node.isTyping)
classes.push('typing');
if (node.isSpeaking)
classes.push('speaking');
if (node.isStreaming)
classes.push('streaming');
if (node.isMuted)
classes.push('muted');
if (node.isDeafened)
classes.push('deafened');
return classes.join(' ');
}
private buildEdgeClasses(edge: DebugNetworkEdge, label: string): string {
const classes: string[] = [edge.kind];
if (!edge.isActive)
classes.push('inactive');
if (label.trim().length > 0)
classes.push('has-label');
return classes.join(' ');
}
private buildNodeLabel(node: DebugNetworkNode): string {
const lines = [node.label];
if (node.secondaryLabel)
lines.push(node.secondaryLabel);
if (node.kind === 'local-client' || node.kind === 'remote-client') {
lines.push(`ID ${this.shortenIdentifier(node.userId ?? node.identity ?? 'unknown')}`);
const statusLine = this.buildCompactStatusLine(node);
if (statusLine)
lines.push(statusLine);
lines.push(`A${node.streams.audio} V${node.streams.video} • ↑${node.textMessages.sent}${node.textMessages.received}`);
if (this.hasHandshakeActivity(node)) {
lines.push(
`HS O${node.handshake.offersSent}/${node.handshake.offersReceived} A${node.handshake.answersSent}/${node.handshake.answersReceived}`
);
lines.push(`ICE ${node.handshake.iceSent}/${node.handshake.iceReceived} • Drop ${node.connectionDrops}`);
} else {
lines.push(`Drop ${node.connectionDrops}`);
}
if (this.hasDownloadMetrics(node)) {
const downloadSummary = [
`F${this.formatMbps(node.downloads.fileMbps)}`,
`A${this.formatMbps(node.downloads.audioMbps)}`,
`V${this.formatMbps(node.downloads.videoMbps)}`
].join(' ');
lines.push(`${downloadSummary}`);
}
}
return lines.join('\n');
}
private buildEdgeLabel(edge: DebugNetworkEdge): string {
if (edge.kind === 'membership')
return edge.isActive ? edge.stateLabel : '';
return edge.label;
}
private getEdgeOrder(kind: DebugNetworkEdge['kind']): number {
switch (kind) {
case 'signaling':
return 0;
case 'peer':
return 1;
case 'membership':
return 2;
}
}
private buildGraphStyles() {
return [
{
selector: 'node',
style: {
'background-color': '#2563eb',
'border-color': '#60a5fa',
'border-width': 2,
color: '#f8fafc',
'font-family': 'Inter, ui-sans-serif, system-ui, sans-serif',
'font-size': 10,
'font-weight': 600,
height: 152,
label: 'data(label)',
padding: 12,
shape: 'round-rectangle',
'text-background-color': '#0f172acc',
'text-background-opacity': 1,
'text-background-padding': 4,
'text-border-radius': 8,
'text-halign': 'center',
'text-max-width': 208,
'text-outline-color': '#0f172a',
'text-outline-width': 0,
'text-valign': 'center',
'text-wrap': 'wrap',
width: 224
}
},
{
selector: 'node.local-client',
style: {
'background-color': '#2563eb',
'border-color': '#93c5fd'
}
},
{
selector: 'node.remote-client',
style: {
'background-color': '#0f766e',
'border-color': '#34d399'
}
},
{
selector: 'node.signaling-server',
style: {
'background-color': '#9a3412',
'border-color': '#fdba74',
height: 82,
shape: 'round-rectangle',
width: 190
}
},
{
selector: 'node.app-server',
style: {
'background-color': '#5b21b6',
'border-color': '#c4b5fd',
height: 82,
shape: 'round-rectangle',
width: 190
}
},
{
selector: 'node.typing',
style: {
'border-color': '#fbbf24',
'border-width': 4
}
},
{
selector: 'node.speaking',
style: {
'border-color': '#34d399',
'border-width': 4,
'overlay-color': '#34d399',
'overlay-opacity': 0.14,
'overlay-padding': 6
}
},
{
selector: 'node.streaming',
style: {
'border-color': '#e879f9'
}
},
{
selector: 'node.muted',
style: {
'background-blacken': 0.28
}
},
{
selector: 'node.deafened',
style: {
'border-style': 'dashed'
}
},
{
selector: 'node.inactive',
style: {
opacity: 0.45
}
},
{
selector: 'edge',
style: {
color: '#e2e8f0',
'curve-style': 'bezier',
'font-family': 'Inter, ui-sans-serif, system-ui, sans-serif',
'font-size': 10,
'line-color': '#64748b',
label: 'data(label)',
opacity: 0.92,
'target-arrow-shape': 'none',
'text-background-color': '#0f172acc',
'text-background-opacity': 0,
'text-background-padding': 0,
'text-margin-y': -10,
'text-max-width': 120,
'text-outline-width': 0,
'text-wrap': 'wrap',
width: 2.5
}
},
{
selector: 'edge.has-label',
style: {
'text-background-opacity': 1,
'text-background-padding': 3
}
},
{
selector: 'edge.signaling',
style: {
'line-color': '#fb923c',
'line-style': 'dashed'
}
},
{
selector: 'edge.peer',
style: {
'line-color': '#22c55e'
}
},
{
selector: 'edge.membership',
style: {
'line-color': '#c084fc',
'line-style': 'dotted',
width: 1.5
}
},
{
selector: 'edge.inactive',
style: {
opacity: 0.3
}
}
];
}
private buildCompactStatusLine(node: DebugNetworkNode): string | null {
const tokens: string[] = [];
if (node.isSpeaking)
tokens.push('Speaking');
else if (node.isTyping)
tokens.push('Typing');
if (node.isMuted)
tokens.push('Muted');
else if (node.isVoiceConnected)
tokens.push('Mic on');
if (node.isStreaming)
tokens.push('Screen');
if (node.pingMs !== null)
tokens.push(`${node.pingMs} ms`);
return tokens.length > 0 ? tokens.join(' • ') : null;
}
private hasHandshakeActivity(node: DebugNetworkNode): boolean {
return node.handshake.offersSent > 0
|| node.handshake.offersReceived > 0
|| node.handshake.answersSent > 0
|| node.handshake.answersReceived > 0
|| node.handshake.iceSent > 0
|| node.handshake.iceReceived > 0;
}
private shortenIdentifier(value: string): string {
if (value.length <= 18)
return value;
return `${value.slice(0, 8)}${value.slice(-6)}`;
}
}

View File

@@ -0,0 +1,208 @@
<div class="border-b border-border bg-card/90 px-4 py-3">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-foreground">Debug Console</span>
@if (activeTab() === 'logs') {
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{ visibleCount() }} visible</span>
} @else {
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>{{ networkSummary().clientCount }} clients · {{ networkSummary().peerConnectionCount }} links</span
>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{
activeTab() === 'logs'
? 'Search logs, filter by level or source, and inspect timestamps inline.'
: 'Visualize signaling, peer links, typing, speaking, streaming, and grouped traffic directly from captured debug data.'
}}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
(click)="toggleDetached()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
{{ getDetachLabel() }}
</button>
<button
type="button"
(click)="toggleAutoScroll()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
[attr.aria-pressed]="autoScroll()"
>
<ng-icon
[name]="autoScroll() ? 'lucidePause' : 'lucidePlay'"
class="h-3.5 w-3.5"
/>
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
</button>
<!-- Export dropdown -->
<div
class="relative"
data-export-menu
>
<button
type="button"
(click)="toggleExportMenu()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
[attr.aria-expanded]="exportMenuOpen()"
aria-haspopup="true"
title="Export logs"
>
<ng-icon
name="lucideDownload"
class="h-3.5 w-3.5"
/>
Export
</button>
@if (exportMenuOpen()) {
<div class="absolute right-0 top-full z-10 mt-1 min-w-[11rem] rounded-lg border border-border bg-card p-1 shadow-xl">
@if (activeTab() === 'logs') {
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Logs</p>
<button
type="button"
(click)="exportLogs('csv')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as CSV
</button>
<button
type="button"
(click)="exportLogs('txt')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as TXT
</button>
} @else {
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Network</p>
<button
type="button"
(click)="exportNetwork('csv')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as CSV
</button>
<button
type="button"
(click)="exportNetwork('txt')"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
>
Export as TXT
</button>
}
</div>
}
</div>
<button
type="button"
(click)="clear()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
Clear
</button>
<button
type="button"
(click)="close()"
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideX"
class="h-3.5 w-3.5"
/>
Close
</button>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
@for (tab of tabs; track tab) {
<button
type="button"
(click)="setActiveTab(tab)"
[class]="getTabButtonClass(tab)"
[attr.aria-pressed]="activeTab() === tab"
>
{{ getTabLabel(tab) }}
</button>
}
</div>
@if (activeTab() === 'logs') {
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_12rem]">
<label class="relative block">
<span class="sr-only">Search logs</span>
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
class="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search messages, payloads, timestamps, and sources"
[value]="searchTerm()"
(input)="onSearchInput($event)"
/>
</label>
<label class="relative block">
<span class="sr-only">Filter by source</span>
<select
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[value]="selectedSource()"
(change)="onSourceChange($event)"
>
<option value="all">All sources</option>
@for (source of sourceOptions(); track source) {
<option [value]="source">{{ source }}</option>
}
</select>
</label>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<div class="mr-1 inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<ng-icon
name="lucideFilter"
class="h-3.5 w-3.5"
/>
Levels
</div>
@for (level of levels; track level) {
<button
type="button"
(click)="toggleLevel(level)"
[class]="getLevelButtonClass(level)"
[attr.aria-pressed]="levelState()[level]"
>
{{ getLevelLabel(level) }}
<span class="ml-1 text-[11px] opacity-80">{{ levelCounts()[level] }}</span>
</button>
}
</div>
} @else {
<div class="mt-3 rounded-xl border border-border/80 bg-background/70 px-3 py-3 text-xs text-muted-foreground">
<p>Traffic is grouped by edge and message type to keep signaling, voice-state, and screen-state chatter readable.</p>
<div class="mt-2 flex flex-wrap gap-2 text-[11px]">
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().typingCount }} typing</span>
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().speakingCount }} speaking</span>
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().streamingCount }} streaming</span>
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().membershipCount }} memberships</span>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,209 @@
import {
Component,
HostListener,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDownload,
lucideFilter,
lucidePause,
lucidePlay,
lucideSearch,
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
type DebugExportFormat = 'csv' | 'txt';
interface DebugNetworkSummary {
clientCount: number;
serverCount: number;
signalingServerCount: number;
peerConnectionCount: number;
membershipCount: number;
messageCount: number;
typingCount: number;
speakingCount: number;
streamingCount: number;
}
@Component({
selector: 'app-debug-console-toolbar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDownload,
lucideFilter,
lucidePause,
lucidePlay,
lucideSearch,
lucideTrash2,
lucideX
})
],
templateUrl: './debug-console-toolbar.component.html'
})
export class DebugConsoleToolbarComponent {
readonly activeTab = input.required<'logs' | 'network'>();
readonly detached = input.required<boolean>();
readonly searchTerm = input.required<string>();
readonly selectedSource = input.required<string>();
readonly sourceOptions = input.required<string[]>();
readonly levelState = input.required<Record<DebugLogLevel, boolean>>();
readonly levelCounts = input.required<Record<DebugLogLevel, number>>();
readonly visibleCount = input.required<number>();
readonly autoScroll = input.required<boolean>();
readonly networkSummary = input.required<DebugNetworkSummary>();
readonly activeTabChange = output<'logs' | 'network'>();
readonly detachToggled = output<undefined>();
readonly searchTermChange = output<string>();
readonly selectedSourceChange = output<string>();
readonly levelToggled = output<DebugLogLevel>();
readonly autoScrollToggled = output<undefined>();
readonly clearRequested = output<undefined>();
readonly closeRequested = output<undefined>();
readonly exportLogsRequested = output<DebugExportFormat>();
readonly exportNetworkRequested = output<DebugExportFormat>();
readonly exportMenuOpen = signal(false);
readonly levels: DebugLogLevel[] = [
'event',
'info',
'warn',
'error',
'debug'
];
readonly tabs: ('logs' | 'network')[] = ['logs', 'network'];
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.exportMenuOpen())
return;
const target = event.target as HTMLElement;
if (!target.closest('[data-export-menu]'))
this.closeExportMenu();
}
setActiveTab(tab: 'logs' | 'network'): void {
this.activeTabChange.emit(tab);
}
toggleDetached(): void {
this.detachToggled.emit(undefined);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchTermChange.emit(input.value);
}
onSourceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedSourceChange.emit(select.value);
}
toggleLevel(level: DebugLogLevel): void {
this.levelToggled.emit(level);
}
toggleAutoScroll(): void {
this.autoScrollToggled.emit(undefined);
}
clear(): void {
this.clearRequested.emit(undefined);
}
close(): void {
this.closeRequested.emit(undefined);
}
toggleExportMenu(): void {
this.exportMenuOpen.update((open) => !open);
}
closeExportMenu(): void {
this.exportMenuOpen.set(false);
}
exportLogs(format: DebugExportFormat): void {
this.exportLogsRequested.emit(format);
this.closeExportMenu();
}
exportNetwork(format: DebugExportFormat): void {
this.exportNetworkRequested.emit(format);
this.closeExportMenu();
}
getDetachLabel(): string {
return this.detached() ? 'Dock' : 'Detach';
}
getTabLabel(tab: 'logs' | 'network'): string {
return tab === 'logs' ? 'Logs' : 'Network';
}
getTabButtonClass(tab: 'logs' | 'network'): string {
const isActive = this.activeTab() === tab;
const base = 'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors';
if (!isActive)
return base + ' border-border bg-transparent text-muted-foreground hover:bg-secondary/60';
return base + ' border-primary/40 bg-primary/10 text-primary';
}
getLevelLabel(level: DebugLogLevel): string {
switch (level) {
case 'event':
return 'Events';
case 'info':
return 'Info';
case 'warn':
return 'Warn';
case 'error':
return 'Error';
case 'debug':
return 'Debug';
}
return 'Unknown';
}
getLevelButtonClass(level: DebugLogLevel): string {
const isActive = this.levelState()[level];
const base = 'rounded-full border px-3 py-1 text-xs font-medium transition-colors';
if (!isActive)
return base + ' border-border bg-transparent text-muted-foreground hover:bg-secondary/60';
switch (level) {
case 'event':
return base + ' border-primary/40 bg-primary/10 text-primary';
case 'info':
return base + ' border-sky-500/40 bg-sky-500/10 text-sky-300';
case 'warn':
return base + ' border-yellow-500/40 bg-yellow-500/10 text-yellow-300';
case 'error':
return base + ' border-destructive/40 bg-destructive/10 text-destructive';
case 'debug':
return base + ' border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300';
}
return base + ' border-border bg-transparent text-muted-foreground';
}
}

View File

@@ -0,0 +1,232 @@
@if (debugging.enabled()) {
@if (showLauncher()) {
@if (launcherVariant() === 'floating') {
<button
type="button"
class="fixed bottom-4 right-4 z-[80] inline-flex h-12 w-12 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-colors hover:bg-secondary"
[class.bg-primary]="isOpen()"
[class.text-primary-foreground]="isOpen()"
[class.border-primary/50]="isOpen()"
(click)="toggleConsole()"
[attr.aria-expanded]="isOpen()"
aria-label="Toggle debug console"
title="Toggle debug console"
>
<ng-icon
name="lucideBug"
class="h-5 w-5"
/>
@if (badgeCount() > 0) {
<span
class="absolute -right-1 -top-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold shadow-sm"
[class.bg-destructive]="hasErrors()"
[class.text-destructive-foreground]="hasErrors()"
[class.bg-secondary]="!hasErrors()"
[class.text-foreground]="!hasErrors()"
>
{{ formatBadgeCount(badgeCount()) }}
</span>
}
</button>
} @else if (launcherVariant() === 'compact') {
<button
type="button"
class="relative inline-flex h-7 w-7 items-center justify-center rounded-lg transition-opacity hover:opacity-90"
[class.bg-primary/20]="isOpen()"
[class.text-primary]="isOpen()"
[class.bg-secondary]="!isOpen()"
[class.text-foreground]="!isOpen()"
(click)="toggleConsole()"
[attr.aria-expanded]="isOpen()"
aria-label="Toggle debug console"
title="Toggle debug console"
>
<ng-icon
name="lucideBug"
class="h-4 w-4"
/>
@if (badgeCount() > 0) {
<span
class="absolute -right-1 -top-1 rounded-full px-1 py-0 text-[9px] font-semibold leading-tight shadow-sm"
[class.bg-destructive]="hasErrors()"
[class.text-destructive-foreground]="hasErrors()"
[class.bg-secondary]="!hasErrors()"
[class.text-foreground]="!hasErrors()"
>
{{ formatBadgeCount(badgeCount()) }}
</span>
}
</button>
} @else {
<button
type="button"
class="relative inline-flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-secondary"
[class.bg-secondary]="isOpen()"
[class.text-foreground]="isOpen()"
[class.text-muted-foreground]="!isOpen()"
(click)="toggleConsole()"
[attr.aria-expanded]="isOpen()"
aria-label="Toggle debug console"
title="Toggle debug console"
>
<ng-icon
name="lucideBug"
class="h-4 w-4"
/>
@if (badgeCount() > 0) {
<span
class="absolute -right-1 -top-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none shadow-sm"
[class.bg-destructive]="hasErrors()"
[class.text-destructive-foreground]="hasErrors()"
[class.bg-secondary]="!hasErrors()"
[class.text-foreground]="!hasErrors()"
>
{{ formatBadgeCount(badgeCount()) }}
</span>
}
</button>
}
}
@if (showPanel() && isOpen()) {
<div class="pointer-events-none fixed inset-0 z-[79]">
<section
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
[class.bottom-20]="!detached()"
[class.right-4]="!detached()"
[style.height.px]="panelHeight()"
[style.width.px]="panelWidth()"
[style.left.px]="detached() ? panelLeft() : null"
[style.top.px]="detached() ? panelTop() : null"
>
<!-- Left resize bar -->
<button
type="button"
class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent"
(mousedown)="startLeftResize($event)"
aria-label="Resize debug console width"
>
<span
class="absolute left-1/2 top-1/2 h-20 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border/60 transition-colors group-hover:bg-primary/60"
></span>
</button>
<!-- Right resize bar -->
<button
type="button"
class="group absolute inset-y-0 right-0 z-[1] w-3 cursor-col-resize bg-transparent"
(mousedown)="startRightResize($event)"
aria-label="Resize debug console width from right"
>
<span
class="absolute left-1/2 top-1/2 h-20 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border/60 transition-colors group-hover:bg-primary/60"
></span>
</button>
<!-- Top resize bar -->
<button
type="button"
class="group relative h-3 w-full cursor-row-resize bg-transparent"
(mousedown)="startTopResize($event)"
aria-label="Resize debug console"
>
<span
class="absolute left-1/2 top-1/2 h-1 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-colors group-hover:bg-primary/50"
></span>
</button>
@if (detached()) {
<button
type="button"
class="flex h-8 w-full cursor-move items-center justify-center border-b border-border bg-background/70 px-4 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:bg-background"
(mousedown)="startDrag($event)"
aria-label="Move debug console"
>
Drag to move
</button>
}
<app-debug-console-toolbar
[activeTab]="activeTab()"
[detached]="detached()"
[searchTerm]="searchTerm()"
[selectedSource]="selectedSource()"
[sourceOptions]="sourceOptions()"
[levelState]="levelState()"
[levelCounts]="levelCounts()"
[visibleCount]="visibleCount()"
[autoScroll]="autoScroll()"
[networkSummary]="networkSummary()"
(activeTabChange)="setActiveTab($event)"
(detachToggled)="toggleDetached()"
(searchTermChange)="updateSearchTerm($event)"
(selectedSourceChange)="updateSelectedSource($event)"
(levelToggled)="toggleLevel($event)"
(autoScrollToggled)="toggleAutoScroll()"
(clearRequested)="clearLogs()"
(closeRequested)="closeConsole()"
(exportLogsRequested)="exportLogs($event)"
(exportNetworkRequested)="exportNetwork($event)"
/>
@if (activeTab() === 'logs') {
<app-debug-console-entry-list
class="min-h-0 flex-1 overflow-hidden"
[entries]="filteredEntries()"
[autoScroll]="autoScroll()"
/>
} @else {
<app-debug-console-network-map
class="min-h-0 flex-1 overflow-hidden"
[snapshot]="networkSnapshot()"
/>
}
<!-- Bottom resize bar -->
<button
type="button"
class="group relative h-3 w-full cursor-row-resize bg-transparent"
(mousedown)="startBottomResize($event)"
aria-label="Resize debug console height from bottom"
>
<span
class="absolute left-1/2 top-1/2 h-1 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-colors group-hover:bg-primary/50"
></span>
</button>
<!-- Bottom-right corner drag handle -->
<button
type="button"
class="group absolute bottom-0 right-0 z-[2] flex h-5 w-5 cursor-nwse-resize items-center justify-center bg-transparent"
(mousedown)="startCornerResize($event)"
aria-label="Resize debug console from corner"
>
<svg
class="h-3 w-3 text-border/80 transition-colors group-hover:text-primary/70"
viewBox="0 0 10 10"
fill="currentColor"
>
<circle
cx="8"
cy="8"
r="1.2"
/>
<circle
cx="4"
cy="8"
r="1.2"
/>
<circle
cx="8"
cy="4"
r="1.2"
/>
</svg>
</button>
</section>
</div>
}
}

View File

@@ -0,0 +1,261 @@
import {
Component,
HostListener,
computed,
effect,
inject,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideBug } from '@ng-icons/lucide';
import { DebuggingService, type DebugLogLevel } from '../../../core/services/debugging.service';
import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component';
import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component';
import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component';
import { DebugConsoleResizeService } from './services/debug-console-resize.service';
import { DebugConsoleExportService, type DebugExportFormat } from './services/debug-console-export.service';
import { DebugConsoleEnvironmentService } from './services/debug-console-environment.service';
type DebugLevelState = Record<DebugLogLevel, boolean>;
type DebugConsoleTab = 'logs' | 'network';
type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
@Component({
selector: 'app-debug-console',
standalone: true,
imports: [
CommonModule,
NgIcon,
DebugConsoleEntryListComponent,
DebugConsoleNetworkMapComponent,
DebugConsoleToolbarComponent
],
viewProviders: [
provideIcons({
lucideBug
})
],
templateUrl: './debug-console.component.html',
host: {
style: 'display: contents;',
'data-debug-console-root': 'true'
}
})
export class DebugConsoleComponent {
readonly debugging = inject(DebuggingService);
readonly resizeService = inject(DebugConsoleResizeService);
readonly exportService = inject(DebugConsoleExportService);
readonly envService = inject(DebugConsoleEnvironmentService);
readonly entries = this.debugging.entries;
readonly isOpen = this.debugging.isConsoleOpen;
readonly networkSnapshot = this.debugging.networkSnapshot;
readonly launcherVariant = input<DebugConsoleLauncherVariant>('floating');
readonly showLauncher = input(true);
readonly showPanel = input(true);
readonly activeTab = signal<DebugConsoleTab>('logs');
readonly detached = signal(false);
readonly searchTerm = signal('');
readonly selectedSource = signal('all');
readonly autoScroll = signal(true);
readonly panelHeight = this.resizeService.panelHeight;
readonly panelWidth = this.resizeService.panelWidth;
readonly panelLeft = this.resizeService.panelLeft;
readonly panelTop = this.resizeService.panelTop;
readonly levelState = signal<DebugLevelState>({
event: true,
info: true,
warn: true,
error: true,
debug: true
});
readonly sourceOptions = computed(() => {
return Array.from(new Set(this.entries().map((entry) => entry.source))).sort();
});
readonly filteredEntries = computed(() => {
const searchTerm = this.searchTerm().trim()
.toLowerCase();
const selectedSource = this.selectedSource();
const levelState = this.levelState();
return this.entries().filter((entry) => {
if (!levelState[entry.level])
return false;
if (selectedSource !== 'all' && entry.source !== selectedSource)
return false;
if (!searchTerm)
return true;
return [
entry.message,
entry.source,
entry.level,
entry.timeLabel,
entry.dateTimeLabel,
entry.payloadText || ''
].some((value) => value.toLowerCase().includes(searchTerm));
});
});
readonly levelCounts = computed<Record<DebugLogLevel, number>>(() => {
const counts: Record<DebugLogLevel, number> = {
event: 0,
info: 0,
warn: 0,
error: 0,
debug: 0
};
for (const entry of this.entries()) {
counts[entry.level] += entry.count;
}
return counts;
});
readonly visibleCount = computed(() => {
return this.filteredEntries().reduce((sum, entry) => sum + entry.count, 0);
});
readonly badgeCount = computed(() => {
const counts = this.levelCounts();
return counts.error > 0 ? counts.error : this.entries().reduce((sum, entry) => sum + entry.count, 0);
});
readonly hasErrors = computed(() => this.levelCounts().error > 0);
readonly networkSummary = computed(() => this.networkSnapshot().summary);
constructor() {
this.resizeService.syncBounds(this.detached());
effect(() => {
const selectedSource = this.selectedSource();
const sourceOptions = this.sourceOptions();
if (selectedSource !== 'all' && !sourceOptions.includes(selectedSource))
this.selectedSource.set('all');
});
}
@HostListener('window:mousemove', ['$event'])
onResizeMove(event: MouseEvent): void {
this.resizeService.onMouseMove(event, this.detached());
}
@HostListener('window:mouseup')
onResizeEnd(): void {
this.resizeService.onMouseUp();
}
@HostListener('window:resize')
onWindowResize(): void {
this.resizeService.syncBounds(this.detached());
}
toggleConsole(): void {
this.debugging.toggleConsole();
}
closeConsole(): void {
this.debugging.closeConsole();
}
updateSearchTerm(value: string): void {
this.searchTerm.set(value);
}
updateSelectedSource(source: string): void {
this.selectedSource.set(source);
}
setActiveTab(tab: DebugConsoleTab): void {
this.activeTab.set(tab);
}
exportLogs(format: DebugExportFormat): void {
const env = this.envService.getEnvironment();
const name = this.envService.getFilenameSafeDisplayName();
this.exportService.exportLogs(
this.filteredEntries(),
format,
env,
name
);
}
exportNetwork(format: DebugExportFormat): void {
const env = this.envService.getEnvironment();
const name = this.envService.getFilenameSafeDisplayName();
this.exportService.exportNetwork(
this.networkSnapshot(),
format,
env,
name
);
}
toggleDetached(): void {
const nextDetached = !this.detached();
this.detached.set(nextDetached);
this.resizeService.syncBounds(nextDetached);
if (nextDetached)
this.resizeService.initializeDetachedPosition();
}
toggleLevel(level: DebugLogLevel): void {
this.levelState.update((current) => ({
...current,
[level]: !current[level]
}));
}
toggleAutoScroll(): void {
this.autoScroll.update((enabled) => !enabled);
}
clearLogs(): void {
this.debugging.clear();
}
startTopResize(event: MouseEvent): void {
this.resizeService.startTopResize(event);
}
startBottomResize(event: MouseEvent): void {
this.resizeService.startBottomResize(event);
}
startLeftResize(event: MouseEvent): void {
this.resizeService.startLeftResize(event);
}
startRightResize(event: MouseEvent): void {
this.resizeService.startRightResize(event);
}
startCornerResize(event: MouseEvent): void {
this.resizeService.startCornerResize(event);
}
startDrag(event: MouseEvent): void {
if (!this.detached())
return;
this.resizeService.startDrag(event);
}
formatBadgeCount(count: number): string {
if (count > 99)
return '99+';
return count.toString();
}
}

View File

@@ -0,0 +1,221 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';
export interface DebugExportEnvironment {
appVersion: string;
displayName: string;
displayServer: string;
gpu: string;
operatingSystem: string;
platform: string;
userAgent: string;
userId: string;
}
@Injectable({ providedIn: 'root' })
export class DebugConsoleEnvironmentService {
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platformService = inject(PlatformService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
getEnvironment(): DebugExportEnvironment {
return {
appVersion: this.resolveAppVersion(),
displayName: this.resolveDisplayName(),
displayServer: this.resolveDisplayServer(),
gpu: this.resolveGpu(),
operatingSystem: this.resolveOperatingSystem(),
platform: this.resolvePlatform(),
userAgent: navigator.userAgent,
userId: this.currentUser()?.id ?? 'Unknown'
};
}
getFilenameSafeDisplayName(): string {
const name = this.resolveDisplayName();
const sanitized = name
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
return sanitized || 'unknown';
}
private resolveDisplayName(): string {
return this.currentUser()?.displayName ?? 'Unknown';
}
private resolveAppVersion(): string {
if (!this.platformService.isElectron)
return 'web';
const electronVersion = this.readElectronVersion();
return electronVersion
? `${electronVersion} (Electron)`
: 'Electron (unknown version)';
}
private resolvePlatform(): string {
if (!this.platformService.isElectron)
return 'Browser';
const os = this.resolveOperatingSystem().toLowerCase();
if (os.includes('windows'))
return 'Windows Electron';
if (os.includes('linux'))
return 'Linux Electron';
if (os.includes('mac'))
return 'macOS Electron';
return 'Electron';
}
private resolveOperatingSystem(): string {
const ua = navigator.userAgent;
if (ua.includes('Windows NT 10.0'))
return 'Windows 10/11';
if (ua.includes('Windows NT'))
return 'Windows';
if (ua.includes('Mac OS X')) {
const match = ua.match(/Mac OS X ([\d._]+)/);
const version = match?.[1]?.replace(/_/g, '.') ?? '';
return version ? `macOS ${version}` : 'macOS';
}
if (ua.includes('Linux')) {
const parts: string[] = ['Linux'];
if (ua.includes('Ubuntu'))
parts.push('(Ubuntu)');
else if (ua.includes('Fedora'))
parts.push('(Fedora)');
else if (ua.includes('Debian'))
parts.push('(Debian)');
return parts.join(' ');
}
return navigator.platform || 'Unknown';
}
private resolveDisplayServer(): string {
if (!navigator.userAgent.includes('Linux'))
return 'N/A';
const electronDisplayServer = this.readElectronDisplayServer();
if (electronDisplayServer)
return electronDisplayServer;
try {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('wayland'))
return 'Wayland';
const isOzone = ua.includes('ozone');
if (isOzone)
return 'Ozone (Wayland likely)';
if (ua.includes('x11'))
return 'X11';
} catch {
// Ignore
}
return this.detectDisplayServerFromEnv();
}
private readElectronDisplayServer(): string | null {
try {
const displayServer = this.electronBridge.getApi()?.linuxDisplayServer;
return typeof displayServer === 'string' && displayServer.trim().length > 0
? displayServer
: null;
} catch {
return null;
}
}
private detectDisplayServerFromEnv(): string {
try {
// Electron may expose env vars
const api = this.electronBridge.getApi();
if (!api)
return 'Unknown (Linux)';
} catch {
// Not available
}
// Best-effort heuristic: check if WebGL context
// mentions wayland in renderer string
const gpu = this.resolveGpu().toLowerCase();
if (gpu.includes('wayland'))
return 'Wayland';
return 'Unknown (Linux)';
}
private resolveGpu(): string {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl')
?? canvas.getContext('experimental-webgl');
if (!gl || !(gl instanceof WebGLRenderingContext))
return 'Unavailable';
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (!ext)
return 'Unavailable (no debug info)';
const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
const renderer = gl.getParameter(
ext.UNMASKED_RENDERER_WEBGL
);
const parts: string[] = [];
if (typeof renderer === 'string' && renderer.length > 0)
parts.push(renderer);
if (typeof vendor === 'string' && vendor.length > 0)
parts.push(`(${vendor})`);
return parts.length > 0
? parts.join(' ')
: 'Unknown';
} catch {
return 'Unavailable';
}
}
private readElectronVersion(): string | null {
try {
const ua = navigator.userAgent;
const match = ua.match(/metoyou\/([\d.]+)/i)
?? ua.match(/Electron\/([\d.]+)/);
return match?.[1] ?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,517 @@
import { Injectable } from '@angular/core';
import type {
DebugLogEntry,
DebugLogLevel,
DebugNetworkEdge,
DebugNetworkNode,
DebugNetworkSnapshot
} from '../../../../core/services/debugging.service';
import type { DebugExportEnvironment } from './debug-console-environment.service';
export type DebugExportFormat = 'csv' | 'txt';
@Injectable({ providedIn: 'root' })
export class DebugConsoleExportService {
exportLogs(
entries: readonly DebugLogEntry[],
format: DebugExportFormat,
env: DebugExportEnvironment,
filenameName: string
): void {
const content = format === 'csv'
? this.buildLogsCsv(entries, env)
: this.buildLogsTxt(entries, env);
const extension = format === 'csv' ? 'csv' : 'txt';
const mime = format === 'csv'
? 'text/csv;charset=utf-8;'
: 'text/plain;charset=utf-8;';
const filename = this.buildFilename(
'debug-logs',
filenameName,
extension
);
this.downloadFile(filename, content, mime);
}
exportNetwork(
snapshot: DebugNetworkSnapshot,
format: DebugExportFormat,
env: DebugExportEnvironment,
filenameName: string
): void {
const content = format === 'csv'
? this.buildNetworkCsv(snapshot, env)
: this.buildNetworkTxt(snapshot, env);
const extension = format === 'csv' ? 'csv' : 'txt';
const mime = format === 'csv'
? 'text/csv;charset=utf-8;'
: 'text/plain;charset=utf-8;';
const filename = this.buildFilename(
'debug-network',
filenameName,
extension
);
this.downloadFile(filename, content, mime);
}
private buildLogsCsv(
entries: readonly DebugLogEntry[],
env: DebugExportEnvironment
): string {
const meta = this.buildCsvMetaSection(env);
const header = 'Timestamp,DateTime,Level,Source,Message,Payload,Count';
const rows = entries.map((entry) =>
[
entry.timeLabel,
entry.dateTimeLabel,
entry.level,
this.escapeCsvField(entry.source),
this.escapeCsvField(entry.message),
this.escapeCsvField(entry.payloadText ?? ''),
entry.count
].join(',')
);
return [
meta,
'',
header,
...rows
].join('\n');
}
private buildLogsTxt(
entries: readonly DebugLogEntry[],
env: DebugExportEnvironment
): string {
const lines: string[] = [
`Debug Logs Export - ${new Date().toISOString()}`,
this.buildSeparator(),
...this.buildTxtEnvLines(env),
this.buildSeparator(),
`Total entries: ${entries.length}`,
this.buildSeparator()
];
for (const entry of entries) {
const prefix = this.buildLevelPrefix(entry.level);
const countSuffix = entry.count > 1 ? ` (×${entry.count})` : '';
lines.push(`[${entry.dateTimeLabel}] ${prefix} [${entry.source}] ${entry.message}${countSuffix}`);
if (entry.payloadText)
lines.push(` Payload: ${entry.payloadText}`);
}
return lines.join('\n');
}
private buildNetworkCsv(
snapshot: DebugNetworkSnapshot,
env: DebugExportEnvironment
): string {
const sections: string[] = [];
sections.push(this.buildCsvMetaSection(env));
sections.push('');
sections.push(this.buildNetworkNodesCsv(snapshot.nodes));
sections.push('');
sections.push(this.buildNetworkEdgesCsv(snapshot.edges));
sections.push('');
sections.push(this.buildNetworkConnectionsCsv(snapshot));
return sections.join('\n');
}
private buildNetworkNodesCsv(nodes: readonly DebugNetworkNode[]): string {
const headerParts = [
'NodeId',
'Kind',
'Label',
'UserId',
'Identity',
'Active',
'VoiceConnected',
'Typing',
'Speaking',
'Muted',
'Deafened',
'Streaming',
'ConnectionDrops',
'PingMs',
'TextSent',
'TextReceived',
'AudioStreams',
'VideoStreams',
'OffersSent',
'OffersReceived',
'AnswersSent',
'AnswersReceived',
'IceSent',
'IceReceived',
'DownloadFileMbps',
'DownloadAudioMbps',
'DownloadVideoMbps'
];
const header = headerParts.join(',');
const rows = nodes.map((node) =>
[
this.escapeCsvField(node.id),
node.kind,
this.escapeCsvField(node.label),
this.escapeCsvField(node.userId ?? ''),
this.escapeCsvField(node.identity ?? ''),
node.isActive,
node.isVoiceConnected,
node.isTyping,
node.isSpeaking,
node.isMuted,
node.isDeafened,
node.isStreaming,
node.connectionDrops,
node.pingMs ?? '',
node.textMessages.sent,
node.textMessages.received,
node.streams.audio,
node.streams.video,
node.handshake.offersSent,
node.handshake.offersReceived,
node.handshake.answersSent,
node.handshake.answersReceived,
node.handshake.iceSent,
node.handshake.iceReceived,
node.downloads.fileMbps ?? '',
node.downloads.audioMbps ?? '',
node.downloads.videoMbps ?? ''
].join(',')
);
return [
'# Nodes',
header,
...rows
].join('\n');
}
private buildNetworkEdgesCsv(edges: readonly DebugNetworkEdge[]): string {
const header = 'EdgeId,Kind,SourceId,TargetId,SourceLabel,TargetLabel,Active,PingMs,State,MessageTotal';
const rows = edges.map((edge) =>
[
this.escapeCsvField(edge.id),
edge.kind,
this.escapeCsvField(edge.sourceId),
this.escapeCsvField(edge.targetId),
this.escapeCsvField(edge.sourceLabel),
this.escapeCsvField(edge.targetLabel),
edge.isActive,
edge.pingMs ?? '',
this.escapeCsvField(edge.stateLabel),
edge.messageTotal
].join(',')
);
return [
'# Edges',
header,
...rows
].join('\n');
}
private buildNetworkConnectionsCsv(snapshot: DebugNetworkSnapshot): string {
const header = 'SourceNode,TargetNode,EdgeKind,Direction,Active';
const rows: string[] = [];
for (const edge of snapshot.edges) {
rows.push(
[
this.escapeCsvField(edge.sourceLabel),
this.escapeCsvField(edge.targetLabel),
edge.kind,
`${edge.sourceLabel}${edge.targetLabel}`,
edge.isActive
].join(',')
);
}
return [
'# Connections',
header,
...rows
].join('\n');
}
private buildNetworkTxt(
snapshot: DebugNetworkSnapshot,
env: DebugExportEnvironment
): string {
const lines: string[] = [];
lines.push(`Network Export - ${new Date().toISOString()}`);
lines.push(this.buildSeparator());
lines.push(...this.buildTxtEnvLines(env));
lines.push(this.buildSeparator());
lines.push('SUMMARY');
lines.push(` Clients: ${snapshot.summary.clientCount}`);
lines.push(` Servers: ${snapshot.summary.serverCount}`);
lines.push(` Signaling servers: ${snapshot.summary.signalingServerCount}`);
lines.push(` Peer connections: ${snapshot.summary.peerConnectionCount}`);
lines.push(` Memberships: ${snapshot.summary.membershipCount}`);
lines.push(` Messages: ${snapshot.summary.messageCount}`);
lines.push(` Typing: ${snapshot.summary.typingCount}`);
lines.push(` Speaking: ${snapshot.summary.speakingCount}`);
lines.push(` Streaming: ${snapshot.summary.streamingCount}`);
lines.push('');
lines.push(this.buildSeparator());
lines.push('NODES');
lines.push(this.buildSeparator());
for (const node of snapshot.nodes)
this.appendNodeTxt(lines, node);
lines.push(this.buildSeparator());
lines.push('EDGES / CONNECTIONS');
lines.push(this.buildSeparator());
for (const edge of snapshot.edges)
this.appendEdgeTxt(lines, edge);
lines.push(this.buildSeparator());
lines.push('CONNECTION MAP');
lines.push(this.buildSeparator());
this.appendConnectionMap(lines, snapshot);
return lines.join('\n');
}
private appendNodeTxt(lines: string[], node: DebugNetworkNode): void {
lines.push(` [${node.kind}] ${node.label} (${node.id})`);
if (node.userId)
lines.push(` User ID: ${node.userId}`);
if (node.identity)
lines.push(` Identity: ${node.identity}`);
const statuses: string[] = [];
if (node.isActive)
statuses.push('Active');
if (node.isVoiceConnected)
statuses.push('Voice');
if (node.isTyping)
statuses.push('Typing');
if (node.isSpeaking)
statuses.push('Speaking');
if (node.isMuted)
statuses.push('Muted');
if (node.isDeafened)
statuses.push('Deafened');
if (node.isStreaming)
statuses.push('Streaming');
if (statuses.length > 0)
lines.push(` Status: ${statuses.join(', ')}`);
if (node.pingMs !== null)
lines.push(` Ping: ${node.pingMs} ms`);
lines.push(` Connection drops: ${node.connectionDrops}`);
lines.push(` Text messages: ↑${node.textMessages.sent}${node.textMessages.received}`);
lines.push(` Streams: Audio ${node.streams.audio}, Video ${node.streams.video}`);
const handshakeLine = [
`Offers ${node.handshake.offersSent}/${node.handshake.offersReceived}`,
`Answers ${node.handshake.answersSent}/${node.handshake.answersReceived}`,
`ICE ${node.handshake.iceSent}/${node.handshake.iceReceived}`
].join(', ');
lines.push(` Handshake: ${handshakeLine}`);
if (node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null) {
const parts = [
`File ${this.formatMbps(node.downloads.fileMbps)}`,
`Audio ${this.formatMbps(node.downloads.audioMbps)}`,
`Video ${this.formatMbps(node.downloads.videoMbps)}`
];
lines.push(` Downloads: ${parts.join(', ')}`);
}
lines.push('');
}
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
const activeLabel = edge.isActive ? 'active' : 'inactive';
lines.push(` [${edge.kind}] ${edge.sourceLabel}${edge.targetLabel} (${activeLabel})`);
if (edge.pingMs !== null)
lines.push(` Ping: ${edge.pingMs} ms`);
if (edge.stateLabel)
lines.push(` State: ${edge.stateLabel}`);
lines.push(` Total messages: ${edge.messageTotal}`);
if (edge.messageGroups.length > 0) {
lines.push(' Message groups:');
for (const group of edge.messageGroups) {
const dir = group.direction === 'outbound' ? '↑' : '↓';
lines.push(` ${dir} [${group.scope}] ${group.type} ×${group.count}`);
}
}
lines.push('');
}
private appendConnectionMap(lines: string[], snapshot: DebugNetworkSnapshot): void {
const nodeMap = new Map(snapshot.nodes.map((node) => [node.id, node]));
for (const node of snapshot.nodes) {
const outgoing = snapshot.edges.filter((edge) => edge.sourceId === node.id);
const incoming = snapshot.edges.filter((edge) => edge.targetId === node.id);
lines.push(` ${node.label} (${node.kind})`);
if (outgoing.length > 0) {
lines.push(' Outgoing:');
for (const edge of outgoing) {
const target = nodeMap.get(edge.targetId);
lines.push(`${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}
if (incoming.length > 0) {
lines.push(' Incoming:');
for (const edge of incoming) {
const source = nodeMap.get(edge.sourceId);
lines.push(`${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
}
}
if (outgoing.length === 0 && incoming.length === 0)
lines.push(' (no connections)');
lines.push('');
}
}
private buildCsvMetaSection(env: DebugExportEnvironment): string {
return [
'# Export Metadata',
'Property,Value',
`Exported By,${this.escapeCsvField(env.displayName)}`,
`User ID,${this.escapeCsvField(env.userId)}`,
`Export Date,${new Date().toISOString()}`,
`App Version,${this.escapeCsvField(env.appVersion)}`,
`Platform,${this.escapeCsvField(env.platform)}`,
`Operating System,${this.escapeCsvField(env.operatingSystem)}`,
`Display Server,${this.escapeCsvField(env.displayServer)}`,
`GPU,${this.escapeCsvField(env.gpu)}`,
`User Agent,${this.escapeCsvField(env.userAgent)}`
].join('\n');
}
private buildTxtEnvLines(
env: DebugExportEnvironment
): string[] {
return [
`Exported by: ${env.displayName}`,
`User ID: ${env.userId}`,
`App version: ${env.appVersion}`,
`Platform: ${env.platform}`,
`OS: ${env.operatingSystem}`,
`Display server: ${env.displayServer}`,
`GPU: ${env.gpu}`,
`User agent: ${env.userAgent}`
];
}
private buildFilename(
prefix: string,
userLabel: string,
extension: string
): string {
const stamp = this.buildTimestamp();
return `${prefix}_${userLabel}_${stamp}.${extension}`;
}
private escapeCsvField(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n'))
return `"${value.replace(/"/g, '""')}"`;
return value;
}
private buildLevelPrefix(level: DebugLogLevel): string {
switch (level) {
case 'event':
return 'EVT';
case 'info':
return 'INF';
case 'warn':
return 'WRN';
case 'error':
return 'ERR';
case 'debug':
return 'DBG';
}
}
private formatMbps(value: number | null): string {
if (value === null)
return '-';
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} Mbps`;
}
private buildTimestamp(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
}
private buildSeparator(): string {
return '─'.repeat(60);
}
private downloadFile(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
requestAnimationFrame(() => {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
});
}
}

View File

@@ -0,0 +1,284 @@
import { Injectable, signal } from '@angular/core';
const STORAGE_KEY = 'metoyou_debug_console_layout';
const DEFAULT_HEIGHT = 520;
const DEFAULT_WIDTH = 832;
const MIN_HEIGHT = 260;
const MIN_WIDTH = 460;
interface PersistedLayout {
height: number;
width: number;
}
@Injectable({ providedIn: 'root' })
export class DebugConsoleResizeService {
readonly panelHeight = signal(DEFAULT_HEIGHT);
readonly panelWidth = signal(DEFAULT_WIDTH);
readonly panelLeft = signal(0);
readonly panelTop = signal(0);
private dragging = false;
private resizingTop = false;
private resizingBottom = false;
private resizingLeft = false;
private resizingRight = false;
private resizingCorner = false;
private resizeOriginX = 0;
private resizeOriginY = 0;
private resizeOriginHeight = DEFAULT_HEIGHT;
private resizeOriginWidth = DEFAULT_WIDTH;
private panelOriginLeft = 0;
private panelOriginTop = 0;
constructor() {
this.loadLayout();
}
get isResizing(): boolean {
return this.resizingTop || this.resizingBottom || this.resizingLeft || this.resizingRight || this.resizingCorner;
}
get isDragging(): boolean {
return this.dragging;
}
startTopResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingTop = true;
this.resizeOriginY = event.clientY;
this.resizeOriginHeight = this.panelHeight();
this.panelOriginTop = this.panelTop();
}
startBottomResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingBottom = true;
this.resizeOriginY = event.clientY;
this.resizeOriginHeight = this.panelHeight();
}
startLeftResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingLeft = true;
this.resizeOriginX = event.clientX;
this.resizeOriginWidth = this.panelWidth();
this.panelOriginLeft = this.panelLeft();
}
startRightResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingRight = true;
this.resizeOriginX = event.clientX;
this.resizeOriginWidth = this.panelWidth();
}
startCornerResize(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.resizingCorner = true;
this.resizeOriginX = event.clientX;
this.resizeOriginY = event.clientY;
this.resizeOriginWidth = this.panelWidth();
this.resizeOriginHeight = this.panelHeight();
}
startDrag(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragging = true;
this.resizeOriginX = event.clientX;
this.resizeOriginY = event.clientY;
this.panelOriginLeft = this.panelLeft();
this.panelOriginTop = this.panelTop();
}
onMouseMove(event: MouseEvent, detached: boolean): void {
if (this.dragging) {
this.updateDetachedPosition(event);
return;
}
if (this.resizingCorner) {
this.updateCornerResize(event, detached);
return;
}
if (this.resizingLeft) {
this.updateLeftResize(event, detached);
return;
}
if (this.resizingRight) {
this.updateRightResize(event, detached);
return;
}
if (this.resizingTop) {
this.updateTopResize(event, detached);
return;
}
if (this.resizingBottom) {
this.updateBottomResize(event, detached);
}
}
onMouseUp(): void {
const wasActive = this.isResizing || this.dragging;
this.dragging = false;
this.resizingTop = false;
this.resizingBottom = false;
this.resizingLeft = false;
this.resizingRight = false;
this.resizingCorner = false;
if (wasActive)
this.persistLayout();
}
syncBounds(detached: boolean): void {
this.panelWidth.update((width) => this.clampWidth(width, detached));
this.panelHeight.update((height) => this.clampHeight(height, detached));
if (detached)
this.clampDetachedPosition();
}
initializeDetachedPosition(): void {
if (this.panelLeft() > 0 || this.panelTop() > 0) {
this.clampDetachedPosition();
return;
}
this.panelLeft.set(this.getMaxLeft(this.panelWidth()));
this.panelTop.set(
this.clamp(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxTop(this.panelHeight()))
);
}
private updateTopResize(event: MouseEvent, detached: boolean): void {
const delta = this.resizeOriginY - event.clientY;
const nextHeight = this.clampHeight(this.resizeOriginHeight + delta, detached);
this.panelHeight.set(nextHeight);
if (!detached)
return;
const originBottom = this.panelOriginTop + this.resizeOriginHeight;
this.panelTop.set(this.clamp(originBottom - nextHeight, 16, this.getMaxTop(nextHeight)));
}
private updateBottomResize(event: MouseEvent, detached: boolean): void {
const delta = event.clientY - this.resizeOriginY;
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + delta, detached));
}
private updateLeftResize(event: MouseEvent, detached: boolean): void {
const delta = this.resizeOriginX - event.clientX;
const nextWidth = this.clampWidth(this.resizeOriginWidth + delta, detached);
this.panelWidth.set(nextWidth);
if (!detached)
return;
const originRight = this.panelOriginLeft + this.resizeOriginWidth;
this.panelLeft.set(this.clamp(originRight - nextWidth, 16, this.getMaxLeft(nextWidth)));
}
private updateRightResize(event: MouseEvent, detached: boolean): void {
const delta = event.clientX - this.resizeOriginX;
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + delta, detached));
}
private updateCornerResize(event: MouseEvent, detached: boolean): void {
const deltaX = event.clientX - this.resizeOriginX;
const deltaY = event.clientY - this.resizeOriginY;
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + deltaX, detached));
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + deltaY, detached));
}
private updateDetachedPosition(event: MouseEvent): void {
const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX);
const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY);
this.panelLeft.set(this.clamp(nextLeft, 16, this.getMaxLeft(this.panelWidth())));
this.panelTop.set(this.clamp(nextTop, 16, this.getMaxTop(this.panelHeight())));
}
private clampHeight(height: number, detached?: boolean): number {
const maxHeight = detached
? Math.max(MIN_HEIGHT, window.innerHeight - 32)
: Math.floor(window.innerHeight * 0.75);
return Math.min(Math.max(height, MIN_HEIGHT), maxHeight);
}
private clampWidth(width: number, _detached?: boolean): number {
const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - 32);
const minWidth = Math.min(MIN_WIDTH, maxWidth);
return Math.min(Math.max(width, minWidth), maxWidth);
}
private clampDetachedPosition(): void {
this.panelLeft.set(this.clamp(this.panelLeft(), 16, this.getMaxLeft(this.panelWidth())));
this.panelTop.set(this.clamp(this.panelTop(), 16, this.getMaxTop(this.panelHeight())));
}
private getMaxLeft(width: number): number {
return Math.max(16, window.innerWidth - width - 16);
}
private getMaxTop(height: number): number {
return Math.max(16, window.innerHeight - height - 16);
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
private loadLayout(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return;
const parsed = JSON.parse(raw) as PersistedLayout;
if (typeof parsed.height === 'number' && parsed.height >= MIN_HEIGHT)
this.panelHeight.set(parsed.height);
if (typeof parsed.width === 'number' && parsed.width >= MIN_WIDTH)
this.panelWidth.set(parsed.width);
} catch {
// Ignore corrupted storage
}
}
private persistLayout(): void {
try {
const layout: PersistedLayout = {
height: this.panelHeight(),
width: this.panelWidth()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
} catch {
// Ignore storage failures
}
}
}

View File

@@ -0,0 +1,65 @@
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<div
class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg"
>
<div class="space-y-3 p-4">
<h4 class="font-semibold text-foreground">Leave Server?</h4>
<div class="space-y-3 text-sm text-muted-foreground">
<p>
Leaving will remove
<span class="font-medium text-foreground">{{ room().name }}</span>
from your My Servers list.
</p>
@if (isOwner()) {
<div class="space-y-2 rounded-md border border-border/80 bg-secondary/20 p-3">
<p class="text-foreground">You are the current owner of this server.</p>
<p>You can optionally promote another member before leaving. If you skip this step, the server will continue without an owner.</p>
@if (ownerCandidates().length > 0) {
<label class="block space-y-1">
<span class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> New owner </span>
<select
class="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[ngModel]="selectedOwnerKey()"
(ngModelChange)="selectedOwnerKey.set($event || '')"
>
<option value="">Skip owner transfer</option>
@for (member of ownerCandidates(); track roomMemberKey(member)) {
<option [value]="roomMemberKey(member)">{{ member.displayName }} - {{ roleLabel(member) }}</option>
}
</select>
</label>
} @else {
<p>No other known members are available to promote right now.</p>
}
</div>
}
</div>
</div>
<div class="flex gap-2 border-t border-border p-3">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
Cancel
</button>
<button
(click)="confirmLeave()"
type="button"
class="flex-1 rounded-lg bg-destructive px-3 py-2 text-sm text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Leave Server
</button>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import {
Component,
HostListener,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
Room,
RoomMember,
User
} from '../../../shared-kernel';
export interface LeaveServerDialogResult {
nextOwnerKey?: string;
}
@Component({
selector: 'app-leave-server-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './leave-server-dialog.component.html'
})
export class LeaveServerDialogComponent {
room = input.required<Room>();
currentUser = input<User | null>(null);
confirmed = output<LeaveServerDialogResult>();
cancelled = output<undefined>();
selectedOwnerKey = signal('');
isOwner = computed(() => {
const room = this.room();
const user = this.currentUser();
if (!room || !user)
return false;
return room.hostId === user.id || room.hostId === user.oderId;
});
ownerCandidates = computed(() => {
const room = this.room();
const user = this.currentUser();
const userIds = new Set([user?.id, user?.oderId].filter((value): value is string => !!value));
return (room.members ?? []).filter((member) => !userIds.has(member.id) && !userIds.has(member.oderId || ''));
});
constructor() {
effect(() => {
this.room();
this.currentUser();
this.selectedOwnerKey.set('');
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.cancelled.emit(undefined);
}
confirmLeave(): void {
this.confirmed.emit(
this.selectedOwnerKey()
? { nextOwnerKey: this.selectedOwnerKey() }
: {}
);
}
roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
roleLabel(member: RoomMember): string {
switch (member.role) {
case 'host':
return 'Owner';
case 'admin':
return 'Admin';
case 'moderator':
return 'Moderator';
default:
return 'Member';
}
}
}

View File

@@ -0,0 +1,86 @@
<div
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close screen share quality dialog"
></div>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto w-full max-w-2xl rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Choose screen share quality</h3>
<p class="mt-1 text-sm text-muted-foreground">
Pick the profile that best matches what you are sharing. You can change the default later in Voice settings.
</p>
@if (includeSystemAudio()) {
<p class="mt-3 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
Computer audio will be shared. MeToYou audio is filtered when supported, and your microphone stays on its normal voice track.
</p>
}
</div>
<div class="grid gap-3 p-5 md:grid-cols-2">
@for (option of qualityOptions; track option.id) {
<button
type="button"
(click)="chooseQuality(option.id)"
class="rounded-xl border px-4 py-4 text-left transition-colors"
[class.border-primary]="activeQuality() === option.id"
[class.bg-primary/10]="activeQuality() === option.id"
[class.text-primary]="activeQuality() === option.id"
[class.border-border]="activeQuality() !== option.id"
[class.bg-secondary/30]="activeQuality() !== option.id"
[class.text-foreground]="activeQuality() !== option.id"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{{ option.label }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ option.description }}
</p>
</div>
<span
class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border text-[10px]"
[class.border-primary]="activeQuality() === option.id"
[class.bg-primary]="activeQuality() === option.id"
[class.text-primary-foreground]="activeQuality() === option.id"
[class.border-border]="activeQuality() !== option.id"
>
@if (activeQuality() === option.id) {
}
</span>
</div>
</button>
}
</div>
<div class="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
(click)="cancelled.emit(undefined)"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
Cancel
</button>
<button
type="button"
(click)="confirm()"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Start sharing
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
import {
Component,
HostListener,
OnInit,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share';
@Component({
selector: 'app-screen-share-quality-dialog',
standalone: true,
imports: [CommonModule],
templateUrl: './screen-share-quality-dialog.component.html'
})
export class ScreenShareQualityDialogComponent implements OnInit {
selectedQuality = input.required<ScreenShareQuality>();
includeSystemAudio = input(false);
confirmed = output<ScreenShareQuality>();
cancelled = output<undefined>();
readonly qualityOptions = SCREEN_SHARE_QUALITY_OPTIONS;
readonly activeQuality = signal<ScreenShareQuality>('balanced');
@HostListener('document:keydown.escape')
onEscape(): void {
this.cancelled.emit(undefined);
}
ngOnInit(): void {
this.activeQuality.set(this.selectedQuality());
}
chooseQuality(quality: ScreenShareQuality): void {
this.activeQuality.set(quality);
}
confirm(): void {
this.confirmed.emit(this.activeQuality());
}
}

View File

@@ -0,0 +1,205 @@
@if (request(); as pickerRequest) {
<div
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm"
(click)="cancel()"
(keydown.enter)="cancel()"
(keydown.space)="cancel()"
role="button"
tabindex="0"
aria-label="Close source picker"
></div>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<section
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
aria-labelledby="screen-share-source-picker-title"
aria-describedby="screen-share-source-picker-description"
tabindex="-1"
>
<header class="border-b border-border p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2
id="screen-share-source-picker-title"
class="text-lg font-semibold text-foreground"
>
Choose what to share
</h2>
<p
id="screen-share-source-picker-description"
class="mt-1 text-sm text-muted-foreground"
>
Select a screen or window to start sharing.
</p>
</div>
<label
class="flex items-center justify-between gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 lg:min-w-80"
for="screen-share-include-system-audio-toggle"
>
<div>
<p class="text-sm font-medium text-foreground">Include system audio</p>
<p class="text-xs text-muted-foreground">Share desktop sound with viewers.</p>
</div>
<span class="relative inline-flex items-center cursor-pointer">
<input
id="screen-share-include-system-audio-toggle"
type="checkbox"
class="sr-only peer"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
/>
<span
class="relative block h-5 w-10 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-transform after:content-[''] peer-checked:after:translate-x-full"
></span>
</span>
</label>
</div>
<div
class="mt-4 flex flex-wrap gap-2"
role="tablist"
aria-label="Share source type"
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
role="tab"
[attr.aria-selected]="activeTab() === 'screen'"
[disabled]="getTabCount('screen') === 0"
[class.border-primary]="activeTab() === 'screen'"
[class.bg-primary/10]="activeTab() === 'screen'"
[class.text-primary]="activeTab() === 'screen'"
[class.border-border]="activeTab() !== 'screen'"
[class.bg-secondary/30]="activeTab() !== 'screen'"
[class.text-foreground]="activeTab() !== 'screen'"
(click)="setActiveTab('screen')"
>
Entire screen
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
{{ getTabCount('screen') }}
</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
role="tab"
[attr.aria-selected]="activeTab() === 'window'"
[disabled]="getTabCount('window') === 0"
[class.border-primary]="activeTab() === 'window'"
[class.bg-primary/10]="activeTab() === 'window'"
[class.text-primary]="activeTab() === 'window'"
[class.border-border]="activeTab() !== 'window'"
[class.bg-secondary/30]="activeTab() !== 'window'"
[class.text-foreground]="activeTab() !== 'window'"
(click)="setActiveTab('window')"
>
Windows
<span class="rounded-full bg-black/10 px-2 py-0.5 text-xs text-current">
{{ getTabCount('window') }}
</span>
</button>
</div>
@if (includeSystemAudio()) {
<p class="mt-3 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
Computer audio will be shared. MeToYou audio is filtered when supported, and your microphone stays on its normal voice track.
</p>
}
</header>
<div class="screen-share-source-picker__body">
@if (filteredSources().length > 0) {
<div
class="screen-share-source-picker__grid"
[class.screen-share-source-picker__grid--screen]="activeTab() === 'screen'"
[class.screen-share-source-picker__grid--window]="activeTab() === 'window'"
>
@for (source of filteredSources(); track trackSource($index, source)) {
<button
#sourceButton
type="button"
class="rounded-xl border px-4 py-4 text-left transition-colors screen-share-source-picker__source"
[attr.aria-pressed]="selectedSourceId() === source.id"
[attr.data-source-id]="source.id"
[class.border-primary]="selectedSourceId() === source.id"
[class.bg-primary/10]="selectedSourceId() === source.id"
[class.text-primary]="selectedSourceId() === source.id"
[class.border-border]="selectedSourceId() !== source.id"
[class.bg-secondary/30]="selectedSourceId() !== source.id"
[class.text-foreground]="selectedSourceId() !== source.id"
(click)="selectSource(source.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<span class="screen-share-source-picker__preview">
<img
[ngSrc]="source.thumbnail"
[alt]="source.name"
fill
/>
</span>
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
<p class="mt-1 text-sm text-muted-foreground">
{{ source.kind === 'screen' ? 'Entire screen' : 'Window' }}
</p>
</div>
<span
class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border text-[10px]"
[class.border-primary]="selectedSourceId() === source.id"
[class.bg-primary]="selectedSourceId() === source.id"
[class.text-primary-foreground]="selectedSourceId() === source.id"
[class.border-border]="selectedSourceId() !== source.id"
>
@if (selectedSourceId() === source.id) {
}
</span>
</div>
</button>
}
</div>
} @else {
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
<div>
<p class="text-sm font-medium text-foreground">No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available</p>
<p class="mt-1 text-sm text-muted-foreground">
{{
activeTab() === 'screen'
? 'No displays were reported by Electron right now.'
: 'Restore the window you want to share and try again.'
}}
</p>
</div>
</div>
}
</div>
<footer class="flex items-center justify-end gap-2 border-t border-border p-4">
<button
type="button"
class="rounded-lg bg-secondary px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="!selectedSourceId()"
(click)="confirmSelection()"
>
Start sharing
</button>
</footer>
</section>
</div>
}

View File

@@ -0,0 +1,64 @@
:host {
display: contents;
}
.screen-share-source-picker__body {
max-height: min(36rem, calc(100vh - 15rem));
overflow: auto;
}
.screen-share-source-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.75rem;
align-content: start;
padding: 1.25rem;
}
.screen-share-source-picker__grid--screen {
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 15rem));
justify-content: start;
}
.screen-share-source-picker__grid--window {
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
.screen-share-source-picker__source {
cursor: pointer;
min-height: 100%;
}
.screen-share-source-picker__source:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.screen-share-source-picker__preview {
display: block;
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 0.875rem;
background: hsl(var(--secondary) / 0.45);
}
.screen-share-source-picker__preview img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
background: hsl(var(--secondary) / 0.3);
}
@media (max-width: 640px) {
.screen-share-source-picker__body {
max-height: calc(100vh - 22rem);
}
.screen-share-source-picker__grid {
grid-template-columns: 1fr;
padding: 1rem;
}
}

View File

@@ -0,0 +1,132 @@
import { CommonModule, NgOptimizedImage } from '@angular/common';
import {
Component,
ElementRef,
HostListener,
computed,
effect,
inject,
signal,
viewChildren
} from '@angular/core';
import {
ScreenShareSourceKind,
ScreenShareSourceOption,
ScreenShareSourcePickerService
} from '../../../domains/screen-share';
@Component({
selector: 'app-screen-share-source-picker',
standalone: true,
imports: [CommonModule, NgOptimizedImage],
templateUrl: './screen-share-source-picker.component.html',
styleUrl: './screen-share-source-picker.component.scss',
host: {
style: 'display: contents;'
}
})
export class ScreenShareSourcePickerComponent {
readonly picker = inject(ScreenShareSourcePickerService);
readonly request = this.picker.request;
readonly sources = computed(() => this.request()?.sources ?? []);
readonly screenSources = computed(() => this.sources().filter((source) => source.kind === 'screen'));
readonly windowSources = computed(() => this.sources().filter((source) => source.kind === 'window'));
readonly filteredSources = computed(() => {
return this.activeTab() === 'screen'
? this.screenSources()
: this.windowSources();
});
readonly hasOpenRequest = computed(() => !!this.request());
readonly activeTab = signal<ScreenShareSourceKind>('screen');
readonly includeSystemAudio = signal(false);
readonly selectedSourceId = signal<string | null>(null);
private readonly sourceButtons = viewChildren<ElementRef<HTMLButtonElement>>('sourceButton');
constructor() {
effect(() => {
const request = this.request();
const defaultTab: ScreenShareSourceKind = request?.sources.some((source) => source.kind === 'screen')
? 'screen'
: 'window';
this.activeTab.set(defaultTab);
this.includeSystemAudio.set(request?.includeSystemAudio ?? false);
});
effect(() => {
const sources = this.filteredSources();
const selectedSourceId = this.selectedSourceId();
if (!sources.some((source) => source.id === selectedSourceId)) {
this.selectedSourceId.set(sources[0]?.id ?? null);
}
if (sources.length === 0) {
return;
}
window.requestAnimationFrame(() => {
const activeSourceId = this.selectedSourceId();
const targetButton = this.sourceButtons().find(
(button) => button.nativeElement.dataset['sourceId'] === activeSourceId
) ?? this.sourceButtons()[0];
targetButton?.nativeElement.focus();
});
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.hasOpenRequest()) {
this.cancel();
}
}
trackSource(_index: number, source: ScreenShareSourceOption): string {
return source.id;
}
setActiveTab(tab: ScreenShareSourceKind): void {
if (!this.getTabSources(tab).length) {
return;
}
this.activeTab.set(tab);
}
getTabCount(tab: ScreenShareSourceKind): number {
return this.getTabSources(tab).length;
}
selectSource(sourceId: string): void {
this.selectedSourceId.set(sourceId);
}
onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked);
}
confirmSelection(): void {
const sourceId = this.selectedSourceId();
if (!sourceId) {
return;
}
this.picker.confirm(sourceId, this.includeSystemAudio());
}
cancel(): void {
this.picker.cancel();
}
private getTabSources(tab: ScreenShareSourceKind): ScreenShareSourceOption[] {
return tab === 'screen'
? this.screenSources()
: this.windowSources();
}
}

View File

@@ -0,0 +1,17 @@
@if (avatarUrl()) {
<img
[ngSrc]="avatarUrl()!"
[width]="sizePx()"
[height]="sizePx()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}

View File

@@ -0,0 +1,50 @@
import { NgOptimizedImage } from '@angular/common';
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-avatar',
standalone: true,
imports: [NgOptimizedImage],
templateUrl: './user-avatar.component.html',
host: {
style: 'display: contents;'
}
})
export class UserAvatarComponent {
name = input.required<string>();
avatarUrl = input<string | undefined | null>();
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
ringClass = input<string>('');
initial(): string {
return this.name()?.charAt(0)
?.toUpperCase() ?? '?';
}
sizeClasses(): string {
switch (this.size()) {
case 'xs': return 'w-7 h-7';
case 'sm': return 'w-8 h-8';
case 'md': return 'w-10 h-10';
case 'lg': return 'w-12 h-12';
}
}
sizePx(): number {
switch (this.size()) {
case 'xs': return 28;
case 'sm': return 32;
case 'md': return 40;
case 'lg': return 48;
}
}
textClass(): string {
switch (this.size()) {
case 'xs': return 'text-xs';
case 'sm': return 'text-sm';
case 'md': return 'text-base font-semibold';
case 'lg': return 'text-lg font-semibold';
}
}
}

View File

@@ -0,0 +1,46 @@
<app-context-menu
[x]="x()"
[y]="y()"
[widthPx]="240"
(closed)="closed.emit(undefined)"
>
<!-- Header -->
<p class="text-xs font-medium text-muted-foreground mb-2 px-2 truncate">{{ displayName() }}</p>
<!-- Mute button + slider + percentage in one row -->
<div class="flex items-center gap-2 px-2 pb-1">
<!-- Mute toggle button -->
<button
type="button"
(click)="toggleMute()"
class="shrink-0 w-7 h-7 inline-flex items-center justify-center rounded transition-colors"
[class]="muteButtonClass()"
[title]="isMuted() ? 'Unmute' : 'Mute'"
>
<ng-icon
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="w-4 h-4"
/>
</button>
<!-- Slider -->
<input
type="range"
min="0"
max="200"
step="1"
[value]="volume()"
(input)="onSliderInput($event)"
class="volume-slider flex-1"
[class.opacity-40]="isMuted()"
[disabled]="isMuted()"
/>
<!-- Percentage label -->
<span
class="text-xs w-10 text-right tabular-nums shrink-0"
[class]="isMuted() ? 'text-muted-foreground line-through' : 'text-foreground'"
>{{ volume() }}%</span
>
</div>
</app-context-menu>

View File

@@ -0,0 +1,47 @@
:host {
display: contents;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: hsl(var(--secondary));
outline: none;
cursor: pointer;
}
.volume-slider:disabled {
cursor: not-allowed;
}
/* Track */
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: hsl(var(--secondary));
}
/* Thumb */
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: hsl(var(--primary));
border: 2px solid hsl(var(--card));
margin-top: -4px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.volume-slider:disabled::-webkit-slider-thumb {
background: hsl(var(--muted-foreground));
cursor: not-allowed;
}

View File

@@ -0,0 +1,63 @@
import {
Component,
input,
output,
inject,
signal,
OnInit
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideVolume2, lucideVolumeX } from '@ng-icons/lucide';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { ContextMenuComponent } from '../context-menu/context-menu.component';
@Component({
selector: 'app-user-volume-menu',
standalone: true,
imports: [NgIcon, ContextMenuComponent],
viewProviders: [provideIcons({ lucideVolume2, lucideVolumeX })],
templateUrl: './user-volume-menu.component.html',
styleUrl: './user-volume-menu.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class UserVolumeMenuComponent implements OnInit {
// eslint-disable-next-line id-length, id-denylist
x = input.required<number>();
// eslint-disable-next-line id-length, id-denylist
y = input.required<number>();
peerId = input.required<string>();
displayName = input.required<string>();
closed = output<undefined>();
private playback = inject(VoicePlaybackService);
volume = signal(100);
isMuted = signal(false);
ngOnInit(): void {
const id = this.peerId();
this.volume.set(this.playback.getUserVolume(id));
this.isMuted.set(this.playback.isUserMuted(id));
}
onSliderInput(event: Event): void {
const val = parseInt((event.target as HTMLInputElement).value, 10);
this.volume.set(val);
this.playback.setUserVolume(this.peerId(), val);
}
toggleMute(): void {
const next = !this.isMuted();
this.isMuted.set(next);
this.playback.setUserMuted(this.peerId(), next);
}
muteButtonClass(): string {
return this.isMuted()
? 'bg-destructive/15 text-destructive hover:bg-destructive/25'
: 'text-muted-foreground hover:bg-secondary hover:text-foreground';
}
}

View File

@@ -0,0 +1,13 @@
/**
* Shared reusable UI components barrel.
*/
export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
export { DebugConsoleComponent } from './components/debug-console/debug-console.component';
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';