ov-components: Enhanced prejoin component

- Introduced background effect feature with options for 'none', 'blur', 'office', and 'nature'.
- Enhanced error handling during device initialization with retry logic and user feedback.
- Updated participant name handling to trim whitespace and clear errors on input change.

style(audio-devices): refactor audio device selection UI

- Redesigned audio device selection to use buttons instead of dropdowns for better UX.
- Improved styling for audio toggle button and device selection menu.

style(video-devices): refactor video device selection UI

- Updated video device selection to use buttons for toggling camera and selecting devices.
- Enhanced styling for video toggle button and device selection menu.

style(lang-selector): improve language selection UI

- Redesigned language selector for better usability with compact and full versions.
- Enhanced styling for language selection buttons and menu items.

style(participant-name-input): refactor participant name input field

- Updated participant name input to use a custom styled input field instead of mat-form-field.
- Improved styling for input field and error handling.

style: general UI improvements across components

- Enhanced overall styling for better consistency and user experience across various components.
master
Carlos Santos 2025-08-19 16:51:42 +02:00
parent 622a2f6707
commit 72e7469012
14 changed files with 1130 additions and 564 deletions

View File

@ -1,64 +1,89 @@
<div class="container" id="prejoin-container"> <div class="prejoin-container" id="prejoin-container">
<!-- Loading State -->
<div *ngIf="isLoading" id="loading-container"> <div *ngIf="isLoading" class="loading-overlay">
<mat-spinner [diameter]="50"></mat-spinner> <div class="loading-content">
<span>{{ 'PREJOIN.PREPARING' | translate }}</span> <mat-spinner [diameter]="40"></mat-spinner>
<span class="loading-text">{{ 'PREJOIN.PREPARING' | translate }}</span>
</div>
</div> </div>
<div *ngIf="!isLoading" id="prejoin-card"> <!-- Main Content -->
<ov-lang-selector *ngIf="!isMinimal" [compact]="true" class="lang-btn" (onLangChanged)="onLangChanged.emit($event)"> <div *ngIf="!isLoading" class="prejoin-main">
</ov-lang-selector> <!-- Header with Language Selector -->
<div class="prejoin-header" *ngIf="!isMinimal">
<ov-lang-selector [compact]="true" class="language-selector" (onLangChanged)="onLangChanged.emit($event)"> </ov-lang-selector>
</div>
<div> <!-- Video Preview Section -->
<div class="video-container"> <div class="video-preview-section">
<div id="video-poster"> <div class="video-preview-container">
<div class="video-frame">
<ov-media-element <ov-media-element
[track]="videoTrack" [track]="videoTrack"
[showAvatar]="!videoTrack || videoTrack.isMuted" [showAvatar]="!videoTrack || videoTrack.isMuted"
[avatarName]="participantName" [avatarName]="participantName"
[avatarColor]="'hsl(48, 100%, 50%)'" [avatarColor]="'hsl(48, 100%, 50%)'"
[isLocal]="true" [isLocal]="true"
></ov-media-element> class="video-element"
</div> >
</div> </ov-media-element>
<div class="media-controls-container"> <!-- Video Controls Overlay -->
<!-- Camera --> <div class="video-overlay">
<div class="video-controls-container" *ngIf="showCameraButton"> <div class="device-controls">
<div class="control-group" *ngIf="showCameraButton">
<ov-video-devices-select <ov-video-devices-select
[compact]="true"
(onVideoDeviceChanged)="onVideoDeviceChanged.emit($event)" (onVideoDeviceChanged)="onVideoDeviceChanged.emit($event)"
(onVideoEnabledChanged)="videoEnabledChanged($event)" (onVideoEnabledChanged)="videoEnabledChanged($event)"
></ov-video-devices-select> class="device-selector"
>
</ov-video-devices-select>
</div> </div>
<!-- Microphone --> <div class="control-group" *ngIf="showMicrophoneButton">
<div class="audio-controls-container" *ngIf="showMicrophoneButton">
<ov-audio-devices-select <ov-audio-devices-select
[compact]="true"
(onAudioDeviceChanged)="onAudioDeviceChanged.emit($event)" (onAudioDeviceChanged)="onAudioDeviceChanged.emit($event)"
(onAudioEnabledChanged)="audioEnabledChanged($event)" (onAudioEnabledChanged)="audioEnabledChanged($event)"
(onDeviceSelectorClicked)="onDeviceSelectorClicked()" (onDeviceSelectorClicked)="onDeviceSelectorClicked()"
></ov-audio-devices-select> class="device-selector"
>
</ov-audio-devices-select>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="participant-name-container" *ngIf="showParticipantName"> <!-- Configuration Section -->
<div class="configuration-section">
<!-- Participant Name Input -->
<div class="input-section" *ngIf="showParticipantName">
<ov-participant-name-input <ov-participant-name-input
[isPrejoinPage]="true" [isPrejoinPage]="true"
[error]="!!_error" [error]="!!_error"
(onNameUpdated)="onParticipantNameChanged($event)" (onNameUpdated)="onParticipantNameChanged($event)"
(onEnterPressed)="onEnterPressed()" (onEnterPressed)="onEnterPressed()"
></ov-participant-name-input> class="name-input"
>
</ov-participant-name-input>
</div> </div>
<div *ngIf="!!_error" id="token-error"> <!-- Error Message -->
<span class="error">{{ _error }}</span> <div *ngIf="!!_error" class="error-message">
<mat-icon class="error-icon">error_outline</mat-icon>
<span class="error-text">{{ _error }}</span>
</div> </div>
<div class="join-btn-container"> <!-- Join Button -->
<button mat-flat-button (click)="join()" id="join-button"> <div class="join-section">
<button mat-flat-button (click)="join()" class="join-button" [disabled]="showParticipantName && !participantName">
<mat-icon class="join-icon">videocam</mat-icon>
{{ 'PREJOIN.JOIN' | translate }} {{ 'PREJOIN.JOIN' | translate }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@ -1,159 +1,350 @@
:host { :host {
.container { display: block;
width: 100%;
height: 100%; height: 100%;
background-color: var(--ov-background-color);
.prejoin-container {
min-height: 100vh;
background: var(--ov-background-color);
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
position: relative;
} }
#loading-container { // Loading State
.loading-overlay {
position: absolute; position: absolute;
top: 40%; top: 0;
left: 0; left: 0;
right: 0; right: 0;
text-align: center; bottom: 0;
color: var(--ov-text-primary-color); display: flex;
.mat-mdc-progress-spinner { align-items: center;
margin: auto; justify-content: center;
} background-color: var(--ov-background-color, #f5f5f5);
} z-index: 1000;
#prejoin-card { .loading-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 16px;
margin: auto;
.loading-text {
color: var(--ov-text-primary-color, #333);
font-size: 16px;
font-weight: 500;
}
.mat-mdc-progress-spinner {
--mdc-circular-progress-active-indicator-color: var(--ov-primary-action-color, #4285f4);
}
}
}
// Main Content
.prejoin-main {
width: 100%;
max-width: 520px;
background: var(--ov-surface-color, #ffffff);
border-radius: var(--ov-surface-radius); border-radius: var(--ov-surface-radius);
width: 90%; box-shadow:
max-width: 370px; 0 8px 32px rgba(0, 0, 0, 0.08),
// max-height: 650px; 0 2px 8px rgba(0, 0, 0, 0.04);
height: min-content; overflow: hidden;
padding: 55px 30px;
background-color: var(--ov-surface-color);
box-shadow: 6px 4px 20px rgba(0, 0, 0, 0.3);
position: relative;
}
::ng-deep .lang-btn {
position: absolute;
top: 10px;
right: 10px;
height: 25px !important;
font-size: 14px !important;
}
::ng-deep .lang-btn mat-icon {
color: var(--ov-text-surface-color) !important;
}
.video-container {
margin: auto;
height: 35vh;
width: 100%;
max-width: 100%;
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center; }
.prejoin-header {
padding: 16px 20px 0;
display: flex;
justify-content: flex-end;
::ng-deep .language-selector {
.mat-mdc-button {
padding: 8px 12px;
border-radius: var(--ov-surface-radius);
background-color: transparent;
border: 1px solid var(--ov-border-color, #e0e0e0);
transition: all 0.2s ease;
&:hover {
background-color: var(--ov-hover-color, #f5f5f5);
}
} }
#video-poster { mat-icon {
height: 100%; color: var(--ov-text-secondary-color, #666) !important;
width: 100%; font-size: 18px;
}
}
}
// Video Preview Section
.video-preview-section {
padding: 24px 24px 20px;
.video-preview-container {
position: relative; position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--ov-surface-radius); border-radius: var(--ov-surface-radius);
overflow: hidden; overflow: hidden;
background: #000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
.video-frame {
width: 100%;
height: 100%;
position: relative;
::ng-deep .video-element {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
} }
.media-controls-container { .video-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
// background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
padding: 16px;
z-index: 9999;
.device-controls {
display: flex; display: flex;
flex-wrap: wrap; gap: 12px;
justify-content: space-between; justify-content: center;
width: 100%; }
margin-top: 15px; }
height: auto; }
} }
.participant-name-container { // Configuration Section
display: block !important; .configuration-section {
width: 100%; padding: 0 24px 24px;
margin: 10px 0; display: flex;
}
.video-controls-container,
.audio-controls-container {
width: calc(50% - 10px);
margin: 5px 0;
}
.join-btn-container {
width: 100%;
margin-top: 15px;
}
#join-button {
background-color: var(--ov-primary-action-color);
color: var(--ov-secondary-action-color);
font-weight: bold;
border-radius: var(--ov-surface-radius);
width: 100%;
height: 50px;
transition: background-color 0.3s;
}
// #join-button:hover {
// background-color: lighten(var(--ov-primary-action-color), 10%);
// }
.error {
font-size: 12px;
font-weight: bold;
font-style: italic;
color: var(--ov-error-color);
margin-top: 5px;
}
@media (max-width: 768px) {
#prejoin-card {
padding: 10px;
}
.video-container {
height: 40vh;
}
.media-controls-container {
flex-direction: column; flex-direction: column;
align-items: center; gap: 20px;
height: auto;
}
.video-controls-container, .input-section {
.audio-controls-container { ::ng-deep .name-input {
.mat-mdc-form-field {
width: 100%; width: 100%;
.mat-mdc-text-field-wrapper {
border-radius: var(--ov-surface-radius);
background-color: var(--ov-input-background, #f8f9fa);
border: 1px solid var(--ov-border-color, #e0e0e0);
transition: all 0.2s ease;
&:hover {
border-color: var(--ov-primary-action-color, #4285f4);
}
&.mdc-text-field--focused {
border-color: var(--ov-primary-action-color, #4285f4);
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
} }
} }
@media (max-width: 800px) and (orientation: landscape) { .mat-mdc-form-field-subscript-wrapper {
.media-controls-container { display: none;
flex-direction: row;
justify-content: space-between;
} }
.video-controls-container, input {
.audio-controls-container { font-size: 16px;
width: 48%; font-weight: 500;
color: var(--ov-text-primary-color, #333);
padding: 16px;
&::placeholder {
color: var(--ov-text-secondary-color, #666);
font-weight: 400;
}
}
}
} }
} }
@media (max-height: 630px) { .error-message {
.video-container { display: flex;
height: 30vh; align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: rgba(244, 67, 54, 0.08);
border: 1px solid rgba(244, 67, 54, 0.2);
border-radius: var(--ov-surface-radius);
color: var(--ov-error-color, #d32f2f);
.error-icon {
font-size: 18px;
width: 18px;
height: 18px;
} }
.media-controls-container { .error-text {
height: auto; font-size: 14px;
font-weight: 500;
}
}
.join-section {
.join-button {
width: 100%;
height: 56px;
background: var(--ov-primary-action-color, #4285f4);
color: white;
border-radius: var(--ov-surface-radius);
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.2);
&:hover:not([disabled]) {
background: var(--ov-primary-action-hover, #3367d6);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(66, 133, 244, 0.3);
}
&:active:not([disabled]) {
transform: translateY(0);
}
&[disabled] {
background: var(--ov-disabled-color, #ccc);
color: var(--ov-disabled-text-color, #999);
cursor: not-allowed;
box-shadow: none;
}
.join-icon {
font-size: 20px;
width: 20px;
height: 20px;
} }
} }
} }
}
// Responsive Design
@media (max-width: 640px) {
.prejoin-container {
padding: 16px;
min-height: 100vh;
}
.prejoin-main {
max-width: 100%;
border-radius: var(--ov-surface-radius);
}
.video-preview-section {
padding: 16px 20px 12px;
.video-preview-container {
aspect-ratio: 4/3;
}
}
.configuration-section {
padding: 0 20px 20px;
gap: 16px;
}
.prejoin-header {
padding: 12px 16px 0;
}
}
@media (max-width: 480px) {
.prejoin-container {
padding: 12px;
}
.video-preview-section {
padding: 12px 16px 8px;
}
.configuration-section {
padding: 0 16px 16px;
}
.video-overlay .device-controls {
gap: 8px;
::ng-deep .device-selector .mat-mdc-icon-button {
width: 44px;
height: 44px;
mat-icon {
font-size: 18px;
}
}
}
}
@media (max-height: 640px) {
.prejoin-container {
align-items: flex-start;
padding-top: 20px;
}
.video-preview-section .video-preview-container {
aspect-ratio: 16/9;
}
}
// Dark theme support
@media (prefers-color-scheme: dark) {
.prejoin-container {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
}
.prejoin-main {
background: #2d2d2d;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.2);
}
.configuration-section .input-section ::ng-deep .name-input .participant-name-input-container .input-wrapper {
background-color: #3a3a3a;
border-color: #555;
}
}
// Animation keyframes
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.prejoin-main {
animation: fadeIn 0.3s ease-out;
}
}

View File

@ -19,6 +19,8 @@ import { TranslateService } from '../../services/translate/translate.service';
import { LocalTrack } from 'livekit-client'; import { LocalTrack } from 'livekit-client';
import { CustomDevice } from '../../models/device.model'; import { CustomDevice } from '../../models/device.model';
import { LangOption } from '../../models/lang.model'; import { LangOption } from '../../models/lang.model';
import { VirtualBackgroundService } from '../../services/virtual-background/virtual-background.service';
import { BackgroundEffect } from '../../models/background-effect.model';
/** /**
* @internal * @internal
@ -56,6 +58,11 @@ export class PreJoinComponent implements OnInit, OnDestroy {
showLogo: boolean = true; showLogo: boolean = true;
showParticipantName: boolean = true; showParticipantName: boolean = true;
// Future feature preparation
backgroundEffectEnabled: boolean = false;
availableBackgroundEffects: BackgroundEffect[] = [];
selectedBackgroundEffect: BackgroundEffect | undefined;
videoTrack: LocalTrack | undefined; videoTrack: LocalTrack | undefined;
audioTrack: LocalTrack | undefined; audioTrack: LocalTrack | undefined;
private tracks: LocalTrack[]; private tracks: LocalTrack[];
@ -74,9 +81,11 @@ export class PreJoinComponent implements OnInit, OnDestroy {
private cdkSrv: CdkOverlayService, private cdkSrv: CdkOverlayService,
private openviduService: OpenViduService, private openviduService: OpenViduService,
private translateService: TranslateService, private translateService: TranslateService,
private virtualBackgroundService: VirtualBackgroundService,
private changeDetector: ChangeDetectorRef private changeDetector: ChangeDetectorRef
) { ) {
this.log = this.loggerSrv.get('PreJoinComponent'); this.log = this.loggerSrv.get('PreJoinComponent');
this.availableBackgroundEffects = this.virtualBackgroundService.getBackgrounds();
} }
async ngOnInit() { async ngOnInit() {
@ -105,14 +114,7 @@ export class PreJoinComponent implements OnInit, OnDestroy {
} }
private async initializeDevices() { private async initializeDevices() {
try { await this.initializeDevicesWithRetry();
this.tracks = await this.openviduService.createLocalTracks();
this.openviduService.setLocalTracks(this.tracks);
this.videoTrack = this.tracks.find((track) => track.kind === 'video');
this.audioTrack = this.tracks.find((track) => track.kind === 'audio');
} catch (error) {
this.log.e('Error creating local tracks:', error);
}
} }
onDeviceSelectorClicked() { onDeviceSelectorClicked() {
@ -122,24 +124,27 @@ export class PreJoinComponent implements OnInit, OnDestroy {
} }
join() { join() {
if (this.showParticipantName && !this.participantName) { if (this.showParticipantName && !this.participantName?.trim()) {
this._error = this.translateService.translate('PREJOIN.NICKNAME_REQUIRED'); this._error = this.translateService.translate('PREJOIN.NICKNAME_REQUIRED');
return; return;
} }
// Clear any previous errors
this._error = undefined;
// Mark tracks as permanent for avoiding to be removed in ngOnDestroy // Mark tracks as permanent for avoiding to be removed in ngOnDestroy
this.shouldRemoveTracksWhenComponentIsDestroyed = false; this.shouldRemoveTracksWhenComponentIsDestroyed = false;
// Assign participant name to the observable if it is defined // Assign participant name to the observable if it is defined
if (this.participantName) { if (this.participantName?.trim()) {
this.libService.updateGeneralConfig({ participantName: this.participantName }); this.libService.updateGeneralConfig({ participantName: this.participantName.trim() });
// Wait for the next tick to ensure the participant name propagates // Wait for the next tick to ensure the participant name propagates
// through the observable before emitting onReadyToJoin // through the observable before emitting onReadyToJoin
this.libService.participantName$ this.libService.participantName$
.pipe( .pipe(
takeUntil(this.destroy$), takeUntil(this.destroy$),
filter((name) => name === this.participantName), filter((name) => name === this.participantName?.trim()),
tap(() => this.onReadyToJoin.emit()) tap(() => this.onReadyToJoin.emit())
) )
.subscribe(); .subscribe();
@ -150,7 +155,11 @@ export class PreJoinComponent implements OnInit, OnDestroy {
} }
onParticipantNameChanged(name: string) { onParticipantNameChanged(name: string) {
if (name) this.participantName = name; this.participantName = name?.trim() || '';
// Clear error when user starts typing
if (this._error && this.participantName) {
this._error = undefined;
}
} }
onEnterPressed() { onEnterPressed() {
@ -210,4 +219,48 @@ export class PreJoinComponent implements OnInit, OnDestroy {
} }
this.onAudioEnabledChanged.emit(enabled); this.onAudioEnabledChanged.emit(enabled);
} }
/**
* Future method for background effects
* @param effect - The background effect to apply
*/
onBackgroundEffectChanged(effect: string) {
// TODO: Implement background effect logic
// this.selectedBackgroundEffect = effect;
// this.log.d('Background effect changed to:', effect);
// this.virtualBackgroundService.applyBackground(this.virtualBackgroundService.getBackgrounds()[0]);
}
/**
* Enhanced error handling with better UX
*/
private handleError(error: any) {
this.log.e('PreJoin component error:', error);
this._error = error.message || 'An unexpected error occurred';
this.changeDetector.markForCheck();
}
/**
* Improved device initialization with error handling
*/
private async initializeDevicesWithRetry(maxRetries: number = 3): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
this.tracks = await this.openviduService.createLocalTracks();
this.openviduService.setLocalTracks(this.tracks);
this.videoTrack = this.tracks.find((track) => track.kind === 'video');
this.audioTrack = this.tracks.find((track) => track.kind === 'audio');
return; // Success, exit retry loop
} catch (error) {
this.log.w(`Device initialization attempt ${attempt} failed:`, error);
if (attempt === maxRetries) {
this.handleError(error);
} else {
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
}
}
} }

View File

@ -1,55 +1,81 @@
<div class="device-container-element" [class.mute-btn]="!isMicrophoneEnabled"> <div class="audio-device-selector" [class.compact]="compact">
<!-- <button mat-stroked-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" id="audio-devices-menu"> <!-- Unified Device Button (Compact Mode) -->
<mat-icon class="audio-icon">mic</mat-icon> @if (compact) {
<span class="device-label"> {{ microphoneSelected.label }} </span> <div class="unified-device-button">
<mat-icon iconPositionEnd class="chevron-icon"> <!-- Main toggle button -->
{{ menuTrigger.menuOpen ? 'expand_less' : 'expand_more' }}
</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item *ngFor="let microphone of microphones">{{ microphone.label }}</button>
</mat-menu> -->
<mat-form-field id="audio-devices-form" *ngIf="microphones.length > 0">
<mat-select
[disabled]="!hasAudioDevices"
[compareWith]="compareObjectDevices"
[value]="microphoneSelected"
(selectionChange)="onMicrophoneSelected($event)"
>
<mat-select-trigger>
<button <button
mat-flat-button mat-flat-button
id="microphone-button" class="toggle-section"
[disableRipple]="true"
[disabled]="!hasAudioDevices || microphoneStatusChanging" [disabled]="!hasAudioDevices || microphoneStatusChanging"
[class.mute-btn]="!isMicrophoneEnabled" [class.device-enabled]="isMicrophoneEnabled"
[class.device-disabled]="!isMicrophoneEnabled"
(click)="toggleMic($event)" (click)="toggleMic($event)"
[matTooltip]="isMicrophoneEnabled ? ('TOOLBAR.MUTE_AUDIO' | translate) : ('TOOLBAR.UNMUTE_AUDIO' | translate)" [matTooltip]="isMicrophoneEnabled ? ('TOOLBAR.MUTE_AUDIO' | translate) : ('TOOLBAR.UNMUTE_AUDIO' | translate)"
[matTooltipDisabled]="!hasAudioDevices" [matTooltipDisabled]="!hasAudioDevices"
> >
<mat-icon *ngIf="isMicrophoneEnabled" id="mic"> mic </mat-icon> <mat-icon>{{ isMicrophoneEnabled ? 'mic' : 'mic_off' }}</mat-icon>
<mat-icon *ngIf="!isMicrophoneEnabled" id="mic_off"> mic_off </mat-icon>
</button> </button>
<span class="selected-text" *ngIf="!isMicrophoneEnabled">{{ 'PANEL.SETTINGS.DISABLED_AUDIO' | translate }}</span>
<span class="selected-text" *ngIf="isMicrophoneEnabled"> {{ microphoneSelected.label }} </span>
</mat-select-trigger>
<mat-option
*ngFor="let microphone of microphones"
[disabled]="!isMicrophoneEnabled"
[value]="microphone"
id="option-{{ microphone.label }}"
>
{{ microphone.label }}
</mat-option>
</mat-select>
</mat-form-field>
<div id="audio-devices-form" *ngIf="microphones.length === 0"> <!-- Dropdown section -->
<div id="mat-select-trigger"> @if (microphones.length > 1 && isMicrophoneEnabled) {
<button mat-icon-button id="microphone-button" class="mute-btn" [disabled]="true"> <button mat-flat-button class="dropdown-section" [matMenuTriggerFor]="microphoneMenu" [disabled]="microphoneStatusChanging">
<mat-icon id="mic_off"> mic_off </mat-icon> <mat-icon>expand_more</mat-icon>
</button> </button>
<span id="audio-devices-not-found"> {{ 'PREJOIN.NO_AUDIO_DEVICE' | translate }} </span> }
</div>
} @else {
<!-- Normal Mode - Input Style Selector -->
<div class="normal-device-selector">
<!-- Input-style Device Selector -->
<div class="device-input-selector" [class.disabled]="!hasAudioDevices || !isMicrophoneEnabled">
<!-- When microphone is enabled -->
@if (isMicrophoneEnabled) {
<div class="device-input-selector">
<button
mat-flat-button
class="selector-button"
[disabled]="microphoneStatusChanging || microphones.length <= 1"
[matMenuTriggerFor]="microphoneMenu"
[attr.aria-expanded]="false"
>
<mat-icon class="device-icon">mic</mat-icon>
<span class="selected-device-name">{{ microphoneSelected?.label || 'No microphone selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="microphones.length > 1">expand_more</mat-icon>
</button>
</div>
} @else {
<!-- When microphone is disabled -->
<div class="device-input-selector disabled">
<div class="selector-button disabled">
<mat-icon class="device-icon">mic_off</mat-icon>
<span class="selected-device-name">
{{ !hasAudioDevices ? ('PREJOIN.NO_AUDIO_DEVICE' | translate) : 'Microphone disabled' }}
</span>
</div> </div>
</div> </div>
}
</div>
</div>
}
<!-- Device Selection Menu (Shared) -->
<mat-menu #microphoneMenu="matMenu" class="device-menu">
@for (microphone of microphones; track microphone.device) {
<button
mat-menu-item
(click)="onMicrophoneSelected({ value: microphone })"
[class.selected]="microphone.device === microphoneSelected.device"
>
<mat-icon *ngIf="microphone.device === microphoneSelected.device">check</mat-icon>
<span>{{ microphone.label }}</span>
</button>
}
</mat-menu>
<!-- No Microphone Available -->
@if (microphones.length === 0) {
<div class="no-device-message">
<mat-icon class="warning-icon">warning</mat-icon>
<span>{{ 'PREJOIN.NO_AUDIO_DEVICE' | translate }}</span>
</div>
}
</div> </div>

View File

@ -1,103 +1,29 @@
$ov-selection-color-btn: #afafaf; @use '../device-selector-shared' as shared;
$ov-selection-color: #cccccc;
:host { :host {
.device-container-element { display: flex;
border-radius: var(--ov-surface-radius); align-items: center;
border: 1px solid $ov-selection-color-btn;
.audio-device-selector {
@include shared.device-selector-base();
// Audio-specific overrides for normal mode
&:not(.compact) {
.normal-device-selector {
.device-input-selector {
&:not(.disabled) {
.selector-button {
// Audio-specific hover effect (simpler than video)
&:hover:not([disabled]) {
border-color: var(--ov-primary-action-color, #4285f4);
}
}
}
}
}
} }
.device-container-element.mute-btn {
border: 1px solid var(--ov-error-color);
} }
#audio-devices-form {
width: 100%;
height: 50px;
} }
#audio-devices-not-found { // Include shared device menu styles
font-size: 13px; @include shared.device-menu-styles();
}
#microphone-button {
color:#000000
}
::ng-deep .mat-mdc-text-field-wrapper,
::ng-deep .mat-mdc-form-field-flex,
::ng-deep .mat-mdc-select-trigger {
height: 50px !important;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none !important;
}
::ng-deep .mat-mdc-text-field-wrapper {
padding-left: 0px;
padding-right: 10px;
background-color: $ov-selection-color !important;
border-radius: var(--ov-surface-radius);
}
::ng-deep .mdc-button--unelevated {
border-top-left-radius: var(--ov-surface-radius);
border-bottom-left-radius: var(--ov-surface-radius);
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px !important;
background-color: $ov-selection-color-btn !important;
width: 48px !important;
min-width: 48px !important;
padding: 0;
height: 50px;
}
::ng-deep .mat-mdc-unelevated-button > .mat-icon {
height: 24px;
width: 24px;
font-size: 24px !important;
margin: auto;
}
::ng-deep .mat-mdc-form-field-infix {
padding: 0px !important;
min-height: 100%;
}
.selected-text {
padding-left: 5px;
}
.mat-icon {
vertical-align: middle;
display: inline-flex;
}
::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::before {
border: 0px !important;
}
::ng-deep .mat-mdc-button-touch-target {
border-radius: var(--ov-surface-radius) !important;
}
.mute-btn {
color: #ffffff !important;
background-color: var(--ov-error-color) !important;
}
}
::ng-deep .mat-mdc-select-panel {
background-color: #ffffff !important;
}
::ng-deep .mat-mdc-option {
padding: 10px 10px !important;
}
::ng-deep .mat-mdc-form-field.mat-focused .mat-mdc-select-arrow {
color: var(--ov-primary-action-color) !important;
}
::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::after {
border-bottom-color: var(--ov-primary-action-color) !important;
}
::ng-deep .mat-mdc-option.mdc-list-item--selected:not(.mdc-list-item--disabled):not(.mat-mdc-option-multiple) {
background-color: $ov-selection-color !important;
}

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CustomDevice } from '../../../models/device.model'; import { CustomDevice } from '../../../models/device.model';
import { DeviceService } from '../../../services/device/device.service'; import { DeviceService } from '../../../services/device/device.service';
@ -16,6 +16,7 @@ import { ParticipantModel } from '../../../models/participant.model';
standalone: false standalone: false
}) })
export class AudioDevicesComponent implements OnInit, OnDestroy { export class AudioDevicesComponent implements OnInit, OnDestroy {
@Input() compact: boolean = false;
@Output() onAudioDeviceChanged = new EventEmitter<CustomDevice>(); @Output() onAudioDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onAudioEnabledChanged = new EventEmitter<boolean>(); @Output() onAudioEnabledChanged = new EventEmitter<boolean>();

View File

@ -0,0 +1,245 @@
// Shared styles for device selectors (video and audio)
// This file contains common styling for both video-devices and audio-devices components
@mixin device-selector-base() {
display: flex;
align-items: center;
width: 100%;
// Compact Mode - Unified Button
&.compact {
.unified-device-button {
display: flex;
background: var(--ov-secondary-action-color);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.toggle-section {
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
width: 50px;
height: 48px;
border: none;
background: transparent;
border-radius: 0;
padding: 0;
transition: all 0.2s ease;
&.device-enabled {
color: var(--ov-primary-action-color, #4285f4);
mat-icon {
color: var(--ov-primary-action-color, #4285f4);
}
}
&.device-disabled {
background: rgba(244, 67, 54, 0.9);
color: white;
mat-icon {
color: white;
}
}
&[disabled] {
background: rgba(150, 150, 150, 0.5);
color: rgba(150, 150, 150, 0.8);
cursor: not-allowed;
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin: 0;
}
}
.dropdown-section {
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
width: 30px;
height: 48px;
border: none;
background: transparent;
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0;
padding: 0;
color: var(--ov-text-secondary-color, #666);
transition: all 0.2s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin: 0;
}
}
}
}
// Normal Mode - Input Style Selector
&:not(.compact) {
.normal-device-selector {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.device-input-selector {
flex: 1;
&:not(.disabled) {
.selector-button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 48px;
padding: 0 16px;
background: var(--ov-surface-color, #ffffff);
border: 2px solid var(--ov-border-color, #e0e0e0);
border-radius: 8px;
color: var(--ov-text-surface-color);
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
text-align: left;
justify-content: flex-start;
&[disabled] {
background: var(--ov-disabled-background, #f5f5f5);
color: var(--ov-disabled-text-color, #999);
cursor: not-allowed;
border-color: var(--ov-disabled-border-color, #ddd);
}
.device-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-secondary-color, #666);
flex-shrink: 0;
}
.selected-device-name {
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.dropdown-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-secondary-color, #666);
flex-shrink: 0;
transition: transform 0.2s ease;
}
&[aria-expanded='true'] .dropdown-icon {
transform: rotate(180deg);
}
}
}
&.disabled {
.selector-button.disabled {
display: flex;
align-items: center;
gap: 12px;
height: 48px;
padding: 0 16px;
background: var(--ov-disabled-background, #f5f5f5);
border: 2px solid var(--ov-disabled-border-color, #ddd);
border-radius: 8px;
color: var(--ov-disabled-text-color, #999);
font-size: 14px;
cursor: not-allowed;
.device-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-error-color, #d32f2f);
}
.selected-device-name {
flex: 1;
font-style: italic;
}
}
}
}
}
}
.no-device-message {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
color: var(--ov-warning-color, #ff9800);
font-size: 12px;
.warning-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
// Shared device menu styles
@mixin device-menu-styles() {
::ng-deep .device-menu.mat-mdc-menu-panel {
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--ov-border-color, #e0e0e0);
overflow: hidden;
background-color: var(--ov-surface-color);
.mat-mdc-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
transition: background-color 0.2s ease;
font-size: 14px;
&:hover {
background-color: var(--ov-hover-color, #f5f5f5);
}
&.selected {
background-color: rgba(66, 133, 244, 0.08);
color: var(--ov-primary-action-color, #4285f4);
mat-icon {
color: var(--ov-primary-action-color, #4285f4);
}
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
span {
flex: 1;
font-weight: 500;
}
}
}
}

View File

@ -1,18 +1,39 @@
<button id="lang-btn-compact" *ngIf="compact" mat-icon-button [matMenuTriggerFor]="menu"> <div class="language-selector-container">
<!-- Compact version (icon only) -->
<button
*ngIf="compact"
mat-icon-button
[matMenuTriggerFor]="menu"
class="compact-lang-button"
[matTooltip]="'Change language'"
>
<mat-icon>translate</mat-icon> <mat-icon>translate</mat-icon>
</button> </button>
<button *ngIf="!compact" mat-flat-button [matMenuTriggerFor]="menu" class="lang-button" id="lang-btn">
<span id="lang-selected-name">{{ langSelected?.name }}</span> <!-- Full version (with text) -->
<mat-icon class="expand-more-icon">expand_more</mat-icon> <button
*ngIf="!compact"
mat-flat-button
[matMenuTriggerFor]="menu"
class="full-lang-button"
>
<mat-icon class="lang-icon">translate</mat-icon>
<span class="lang-name">{{ langSelected?.name }}</span>
<mat-icon class="expand-icon">expand_more</mat-icon>
</button> </button>
<mat-menu #menu="matMenu">
<!-- Language Menu -->
<mat-menu #menu="matMenu" class="language-menu">
<button <button
mat-menu-item mat-menu-item
*ngFor="let lang of languages" *ngFor="let lang of languages"
(click)="onLangSelected(lang.lang)" (click)="onLangSelected(lang.lang)"
[attr.id]="'lang-opt-' + lang.lang" [attr.id]="'lang-opt-' + lang.lang"
class="lang-menu-opt" [class.selected]="langSelected?.lang === lang.lang"
class="language-option"
> >
<span>{{ lang.name }}</span> <mat-icon *ngIf="langSelected?.lang === lang.lang" class="check-icon">check</mat-icon>
<span class="lang-option-name">{{ lang.name }}</span>
</button> </button>
</mat-menu> </mat-menu>
</div>

View File

@ -1,21 +1,120 @@
$ov-surface-color-lighter: color-mix(in srgb, var(--ov-surface-color), #fff 5%); :host {
display: inline-block;
.lang-button { .language-selector-container {
background-color: var(--ov-primary-action-color) !important; .compact-lang-button {
color: var(--ov-secondary-action-color) !important; width: 40px;
} height: 40px;
.lang-button .mat-icon { background: rgba(255, 255, 255, 0.9);
color: var(--ov-secondary-action-color); backdrop-filter: blur(10px);
border: 1px solid var(--ov-border-color, #e0e0e0);
border-radius: 10px;
transition: all 0.2s ease;
color: var(--ov-text-secondary-color, #666);
} &:hover {
::ng-deep .mat-mdc-menu-panel { background: rgba(255, 255, 255, 1);
border-radius: var(--ov-surface-radius) !important; border-color: var(--ov-primary-action-color, #4285f4);
background-color: $ov-surface-color-lighter !important; transform: scale(1.02);
box-shadow: 1px 1px 5px 0px rgba(0, 0, 0, 0.2) !important;
} }
::ng-deep .mat-mdc-menu-item, mat-icon {
.mat-mdc-menu-item:visited, font-size: 18px;
.mat-mdc-menu-item:link { width: 18px;
color: var(--ov-text-surface-color) !important; height: 18px;
}
}
.full-lang-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--ov-surface-color, #ffffff);
border: 2px solid var(--ov-border-color, #e0e0e0);
border-radius: 12px;
transition: all 0.2s ease;
color: var(--ov-text-primary-color, #333);
font-weight: 500;
&:hover {
border-color: var(--ov-primary-action-color, #4285f4);
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.1);
}
.lang-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-secondary-color, #666);
}
.lang-name {
font-size: 14px;
font-weight: 500;
}
.expand-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--ov-text-secondary-color, #666);
transition: transform 0.2s ease;
}
&[aria-expanded="true"] .expand-icon {
transform: rotate(180deg);
}
}
}
}
::ng-deep .language-menu {
.mat-mdc-menu-panel {
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--ov-border-color, #e0e0e0);
overflow: hidden;
background: var(--ov-surface-color, #ffffff);
}
.language-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
transition: background-color 0.2s ease;
font-size: 14px;
min-height: 48px;
&:hover {
background-color: var(--ov-hover-color, #f5f5f5);
}
&.selected {
background-color: rgba(66, 133, 244, 0.08);
color: var(--ov-primary-action-color, #4285f4);
.check-icon {
color: var(--ov-primary-action-color, #4285f4);
}
}
.check-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.lang-option-name {
flex: 1;
font-weight: 500;
color: var(--ov-text-primary-color, #333);
}
&.selected .lang-option-name {
color: var(--ov-primary-action-color, #4285f4);
font-weight: 600;
}
}
} }

View File

@ -1,20 +1,17 @@
<div id="name-input-container" [ngClass]="{ warn: !name }"> <div class="participant-name-input-container" [class.error]="error">
<mat-form-field id="name-form" [ngClass]="{ error: error }"> <div class="input-wrapper">
<mat-select-trigger> <mat-icon class="input-icon">person</mat-icon>
<button mat-flat-button disabled>
<mat-icon>person</mat-icon>
</button>
</mat-select-trigger>
<input <input
id="name-input" id="name-input"
matInput
(change)="updateName()"
type="text" type="text"
maxlength="20" maxlength="20"
[(ngModel)]="name" [(ngModel)]="name"
autocomplete="off" autocomplete="off"
[disabled]="!isPrejoinPage" [disabled]="!isPrejoinPage"
(change)="updateName()"
(keypress)="eventKeyPress($event)" (keypress)="eventKeyPress($event)"
[placeholder]="'PREJOIN.NICKNAME' | translate"
class="name-input-field"
/> />
</mat-form-field> </div>
</div> </div>

View File

@ -1,67 +1,71 @@
$ov-selection-color-btn: #afafaf;
$ov-selection-color: #cccccc;
:host { :host {
#name-input-container { display: block;
height: 70px;
border-radius: var(--ov-surface-radius);
}
#name-input-container mat-form-field {
width: 100%; width: 100%;
color: var(--ov-secondary-action-color);
}
::ng-deep .mat-mdc-form-field-infix { .participant-name-input-container {
display: inline-flex; width: 100%;
padding: 0px !important;
} .input-wrapper {
::ng-deep .mat-mdc-text-field-wrapper { display: flex;
align-items: center;
background: var(--ov-input-background, #f8f9fa);
border: 2px solid var(--ov-border-color, #e0e0e0);
border-radius: 12px;
padding: 0; padding: 0;
height: 70px; transition: all 0.2s ease;
background-color: $ov-selection-color !important; position: relative;
border-radius: var(--ov-surface-radius); overflow: hidden;
}
::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::before { &:focus-within {
border: 0px !important; border-color: var(--ov-primary-action-color, #4285f4);
} box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
::ng-deep .mdc-button--unelevated {
border-top-left-radius: var(--ov-surface-radius) !important;
border-bottom-left-radius: var(--ov-surface-radius) !important;
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px !important;
background-color: $ov-selection-color-btn !important;
width: 48px !important;
min-width: 48px !important;
padding: 0;
height: 70px;
} }
.error { .input-icon {
::ng-deep .mdc-button--unelevated { display: flex;
background-color: var(--ov-error-color) !important; align-items: center;
justify-content: center;
width: 48px;
height: 56px;
background: var(--ov-surface-secondary, #f0f0f0);
color: var(--ov-text-secondary-color, #666);
font-size: 20px;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
flex-shrink: 0;
}
.name-input-field {
flex: 1;
height: 56px;
padding: 0 16px;
border: none;
outline: none;
background: transparent;
font-size: 16px;
font-weight: 500;
&::placeholder {
color: var(--ov-text-secondary-color, #666);
font-weight: 400;
}
&:disabled {
background: var(--ov-disabled-background, #f5f5f5);
color: var(--ov-disabled-text-color, #999);
cursor: not-allowed;
}
} }
} }
::ng-deep .mat-mdc-unelevated-button > .mat-icon { &.error .input-wrapper {
height: 24px; border-color: var(--ov-error-color, #d32f2f);
width: 24px; box-shadow: 0 0 0 3px rgba(211, 47, 47, 0.1);
font-size: 24px !important;
margin: auto;
color: #000000 !important;
}
input { .input-icon {
padding-left: 10px !important; background: rgba(211, 47, 47, 0.1);
border-top-right-radius: var(--ov-surface-radius) !important; color: var(--ov-error-color, #d32f2f);
border-bottom-right-radius: var(--ov-surface-radius) !important; }
border-bottom-left-radius: 0px !important;
border-top-left-radius: 0px !important;
border: 1px solid $ov-selection-color-btn;
color: #000000;
caret-color: #000000 !important;
} }
} }
::ng-deep .mat-mdc-form-field.mat-focused .mat-mdc-select-arrow {
color: var(--ov-primary-action-color) !important;
} }

View File

@ -1,40 +1,72 @@
<div class="device-container-element" [class.mute-btn]="!isCameraEnabled"> <div class="video-device-selector" [class.compact]="compact">
<mat-form-field id="video-devices-form" *ngIf="cameras.length > 0"> <!-- Unified Device Button (Compact Mode) -->
<mat-select @if (compact) {
[disabled]="!hasVideoDevices" <div class="unified-device-button">
[compareWith]="compareObjectDevices" <!-- Main toggle button -->
[value]="cameraSelected"
(selectionChange)="onCameraSelected($event)"
>
<mat-select-trigger id="mat-select-trigger">
<button <button
mat-flat-button mat-flat-button
id="camera-button" class="toggle-section"
[disableRipple]="true"
[disabled]="!hasVideoDevices || cameraStatusChanging" [disabled]="!hasVideoDevices || cameraStatusChanging"
[class.mute-btn]="!isCameraEnabled" [class.device-enabled]="isCameraEnabled"
[class.device-disabled]="!isCameraEnabled"
(click)="toggleCam($event)" (click)="toggleCam($event)"
[matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)" [matTooltip]="isCameraEnabled ? ('TOOLBAR.STOP_VIDEO' | translate) : ('TOOLBAR.START_VIDEO' | translate)"
[matTooltipDisabled]="!hasVideoDevices" [matTooltipDisabled]="!hasVideoDevices"
> >
<mat-icon *ngIf="isCameraEnabled" id="videocam"> videocam </mat-icon> <mat-icon>{{ isCameraEnabled ? 'videocam' : 'videocam_off' }}</mat-icon>
<mat-icon *ngIf="!isCameraEnabled" id="videocam_off"> videocam_off </mat-icon>
</button> </button>
<span class="selected-text" *ngIf="!isCameraEnabled"> {{ 'PANEL.SETTINGS.DISABLED_VIDEO' | translate }} </span>
<span class="selected-text" *ngIf="isCameraEnabled"> {{ cameraSelected.label }} </span>
</mat-select-trigger>
<mat-option *ngFor="let camera of cameras" [disabled]="!isCameraEnabled" [value]="camera" id="option-{{ camera.label }}">
{{ camera.label }}
</mat-option>
</mat-select>
</mat-form-field>
<div id="video-devices-form" *ngIf="cameras.length === 0"> <!-- Dropdown section -->
<div id="mat-select-trigger"> @if (isCameraEnabled && cameras.length > 1) {
<button mat-icon-button id="camera-button" class="mute-btn" [disabled]="true"> <button mat-flat-button class="dropdown-section" [matMenuTriggerFor]="cameraMenu" [disabled]="cameraStatusChanging">
<mat-icon id="videocam_off"> videocam_off </mat-icon> <mat-icon>expand_more</mat-icon>
</button> </button>
<span id="video-devices-not-found"> {{ 'PREJOIN.NO_VIDEO_DEVICE' | translate }} </span> }
</div>
} @else {
<!-- Normal Mode - Input-style Selector -->
<div class="normal-device-selector">
<!-- Device Selector (Input Style) -->
@if (isCameraEnabled) {
<div class="device-input-selector">
<button
mat-flat-button
class="selector-button"
[matMenuTriggerFor]="cameraMenu"
[disabled]="cameraStatusChanging || cameras.length <= 1"
>
<mat-icon class="device-icon">videocam</mat-icon>
<span class="selected-device-name">{{ cameraSelected?.label || 'No camera selected' }}</span>
<mat-icon class="dropdown-icon" *ngIf="cameras.length > 1">expand_more</mat-icon>
</button>
</div>
} @else {
<!-- Disabled state message -->
<div class="device-input-selector disabled">
<div class="selector-button disabled">
<mat-icon class="device-icon">videocam_off</mat-icon>
<span class="selected-device-name">{{ 'PANEL.SETTINGS.DISABLED_VIDEO' | translate }}</span>
</div> </div>
</div> </div>
}
</div>
}
<!-- Device Selection Menu (Shared) -->
<mat-menu #cameraMenu="matMenu" class="device-menu">
@for (camera of cameras; track camera.device) {
<button mat-menu-item (click)="onCameraSelected({ value: camera })" [class.selected]="camera.device === cameraSelected?.device">
<mat-icon *ngIf="camera.device === cameraSelected?.device" class="check-icon">check</mat-icon>
<span>{{ camera.label }}</span>
</button>
}
</mat-menu>
<!-- No Camera Available -->
@if (cameras.length === 0) {
<div class="no-device-message">
<mat-icon class="warning-icon">warning</mat-icon>
<span>{{ 'PREJOIN.NO_VIDEO_DEVICE' | translate }}</span>
</div>
}
</div> </div>

View File

@ -1,106 +1,51 @@
@use '../device-selector-shared' as shared;
$ov-selection-color-btn: #afafaf; $ov-selection-color-btn: #afafaf;
$ov-selection-color: #cccccc; $ov-selection-color: #cccccc;
:host { :host {
.device-container-element { display: flex;
border-radius: var(--ov-surface-radius); align-items: center;
border: 1px solid $ov-selection-color-btn;
.video-device-selector {
@include shared.device-selector-base();
// Video-specific overrides for compact mode
&.compact {
.unified-device-button {
.toggle-section {
display: flex-end; // Video-specific styling
} }
.device-container-element.mute-btn {
border: 1px solid var(--ov-error-color);
} }
#video-devices-form {
width: 100%;
height: 50px;
} }
#video-devices-not-found { // Video-specific overrides for normal mode
font-size: 13px; &:not(.compact) {
.normal-device-selector {
.device-input-selector {
&:not(.disabled) {
.selector-button {
// Video-specific hover effect with box-shadow
&:hover:not([disabled]) {
background-color: white !important;
border-color: var(--ov-primary-action-color);
}
}
}
}
}
}
}
} }
#camera-button { // Include shared device menu styles
color: #000000; @include shared.device-menu-styles();
}
::ng-deep .mat-mdc-text-field-wrapper, // Video-specific additional styles
::ng-deep .mat-mdc-form-field-flex,
::ng-deep .mat-mdc-select-trigger {
height: 50px !important;
}
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none !important;
}
::ng-deep .mat-mdc-text-field-wrapper {
padding-left: 0px;
padding-right: 10px;
background-color: $ov-selection-color !important;
border-radius: var(--ov-surface-radius);
}
::ng-deep .mdc-button--unelevated {
border-top-left-radius: var(--ov-surface-radius);
border-bottom-left-radius: var(--ov-surface-radius);
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px !important;
background-color: $ov-selection-color-btn !important;
width: 48px !important;
min-width: 48px !important;
padding: 0;
height: 50px;
}
::ng-deep .mat-mdc-unelevated-button > .mat-icon {
height: 24px;
width: 24px;
font-size: 24px !important;
margin: auto;
}
::ng-deep .mat-mdc-form-field-infix {
padding: 0px !important;
min-height: 100%;
}
.selected-text {
padding-left: 5px;
}
.mat-icon {
vertical-align: middle;
display: inline-flex;
}
::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::before {
border: 0px !important;
}
::ng-deep .mat-mdc-button-touch-target {
border-radius: var(--ov-surface-radius) !important;
}
.mute-btn {
color: #ffffff !important;
background-color: var(--ov-error-color) !important;
}
}
::ng-deep .mat-mdc-select-panel {
background-color: var(--ov-surface-color) !important;
}
::ng-deep .mat-mdc-select-panel {
background-color: #e2e2e2 !important;
}
::ng-deep .mat-mdc-option {
padding: 10px 10px !important;
}
::ng-deep .mat-mdc-form-field.mat-focused .mat-mdc-select-arrow {
color: var(--ov-primary-action-color-lighter) !important;
}
::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::after { ::ng-deep .mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::after {
border-bottom-color: var(--ov-primary-action-color-lighter) !important; border-bottom-color: var(--ov-primary-action-color-lighter) !important;
} }
::ng-deep .mat-mdc-option.mdc-list-item--selected:not(.mdc-list-item--disabled):not(.mat-mdc-option-multiple) { ::ng-deep .mat-mdc-option.mdc-list-item--selected:not(.mdc-list-item--disabled):not(.mat-mdc-option-multiple) {
background-color: $ov-selection-color !important; background-color: $ov-selection-color !important;
} }

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CustomDevice } from '../../../models/device.model'; import { CustomDevice } from '../../../models/device.model';
import { DeviceService } from '../../../services/device/device.service'; import { DeviceService } from '../../../services/device/device.service';
@ -16,6 +16,7 @@ import { ParticipantModel } from '../../../models/participant.model';
standalone: false standalone: false
}) })
export class VideoDevicesComponent implements OnInit, OnDestroy { export class VideoDevicesComponent implements OnInit, OnDestroy {
@Input() compact: boolean = false;
@Output() onVideoDeviceChanged = new EventEmitter<CustomDevice>(); @Output() onVideoDeviceChanged = new EventEmitter<CustomDevice>();
@Output() onVideoEnabledChanged = new EventEmitter<boolean>(); @Output() onVideoEnabledChanged = new EventEmitter<boolean>();