Move toju-app into own its folder
This commit is contained in:
@@ -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">
|
||||
Couldn’t 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
13
toju-app/src/app/shared/index.ts
Normal file
13
toju-app/src/app/shared/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user