Fix bugs and clean noise reduction
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
HostListener
|
||||
signal,
|
||||
HostListener,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* Generic positioned context-menu overlay.
|
||||
* Generic positioned context-menu overlay with automatic viewport clamping.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
@@ -17,6 +22,13 @@ import {
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* For pixel-based widths (e.g. sliders), use `[widthPx]` instead of `[width]`:
|
||||
* ```html
|
||||
* <app-context-menu [x]="menuX()" [y]="menuY()" [widthPx]="240" (closed)="closeMenu()">
|
||||
* ...custom content...
|
||||
* </app-context-menu>
|
||||
* ```
|
||||
*
|
||||
* Built-in item classes are available via the host styles:
|
||||
* - `.context-menu-item` - normal item
|
||||
* - `.context-menu-item-danger` - destructive (red) item
|
||||
@@ -25,68 +37,73 @@ import {
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- Invisible backdrop that captures clicks outside -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
(click)="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
|
||||
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
[class]="width()"
|
||||
[style.left.px]="x()"
|
||||
[style.top.px]="y()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
: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;
|
||||
}
|
||||
`
|
||||
]
|
||||
templateUrl: './context-menu.component.html',
|
||||
styleUrl: './context-menu.component.scss'
|
||||
})
|
||||
export class ContextMenuComponent {
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
||||
/** Horizontal position (px from left). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
x = input.required<number>();
|
||||
/** Vertical position (px from top). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
y = input.required<number>();
|
||||
/** Tailwind width class for the panel (default `w-48`). */
|
||||
/** Tailwind width class for the panel (default `w-48`). Ignored when `widthPx` is set. */
|
||||
width = input<string>('w-48');
|
||||
/** Optional fixed width in pixels (overrides `width`). Useful for custom content like sliders. */
|
||||
widthPx = input<number | null>(null);
|
||||
/** Emitted when the menu should close (backdrop click or Escape). */
|
||||
closed = output<undefined>();
|
||||
|
||||
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
|
||||
|
||||
/** Viewport-clamped X position. */
|
||||
clampedX = signal(0);
|
||||
/** Viewport-clamped Y position. */
|
||||
clampedY = signal(0);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initial clamp with estimated dimensions
|
||||
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
|
||||
this.clampedY.set(this.clampY(this.y(), 80));
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Refine with actual rendered dimensions
|
||||
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;
|
||||
|
||||
// Parse Tailwind w-XX class to approximate pixel width
|
||||
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,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,89 @@
|
||||
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 '../../../features/voice/voice-controls/services/voice-playback.service';
|
||||
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
|
||||
/**
|
||||
* Context-menu overlay that lets the local user adjust the playback
|
||||
* volume of a specific remote voice-channel participant (0%-200%)
|
||||
* and toggle per-user mute.
|
||||
*
|
||||
* Wraps `<app-context-menu>` for consistent positioning, backdrop,
|
||||
* escape handling and viewport clamping.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* @if (showVolumeMenu()) {
|
||||
* <app-user-volume-menu
|
||||
* [x]="menuX()"
|
||||
* [y]="menuY()"
|
||||
* [peerId]="targetPeerId()"
|
||||
* [displayName]="targetName()"
|
||||
* (closed)="showVolumeMenu.set(false)"
|
||||
* />
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@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 {
|
||||
/** Horizontal position (px from left). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
x = input.required<number>();
|
||||
/** Vertical position (px from top). */
|
||||
// eslint-disable-next-line id-length, id-denylist
|
||||
y = input.required<number>();
|
||||
/** Remote peer identifier (oderId). */
|
||||
peerId = input.required<string>();
|
||||
/** Display name shown in the header. */
|
||||
displayName = input.required<string>();
|
||||
/** Emitted when the menu should close. */
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user