Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
@if (showFloatingControls()) {
|
||||
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
|
||||
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
|
||||
<div class="p-2 flex items-center gap-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Voice status indicator -->
|
||||
<div class="flex items-center gap-1 px-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-border"></div>
|
||||
|
||||
<!-- Voice controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<app-debug-console
|
||||
launcherVariant="compact"
|
||||
[showPanel]="false"
|
||||
/>
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
type="button"
|
||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/voice-session.facade';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-voice-controls',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
/**
|
||||
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||
*/
|
||||
export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
// Voice state from services
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
voiceSession = this.voiceSessionService.voiceSession;
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
if (settings.outputDevice) {
|
||||
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate back to the voice-connected server. */
|
||||
navigateToServer(): void {
|
||||
this.voiceSessionService.navigateToVoiceServer();
|
||||
}
|
||||
|
||||
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
// Broadcast deafen state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle screen sharing on or off. */
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
|
||||
// Broadcast voice disconnect
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disable voice
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: { isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
// Reset local state
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (isActive) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
} catch (_error) {
|
||||
// Screen share request was denied or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user