ov-components: Enhanced camera track handling and processor application in virtual background service

master
Carlos Santos 2025-08-20 13:24:44 +02:00
parent 72e7469012
commit 68d855a245
11 changed files with 323 additions and 222 deletions

View File

@ -1,10 +1,17 @@
<div class="panel-container" id="background-effects-container">
<div class="panel-container" id="background-effects-container" [class.prejoin-mode]="mode === 'prejoin'">
@if (mode === 'meeting') {
<div class="panel-header-container">
<h3 class="panel-title">{{ 'PANEL.BACKGROUND.TITLE' | translate }}</h3>
<button class="panel-close-button" mat-icon-button matTooltip="{{ 'PANEL.CLOSE' | translate }}" (click)="close()">
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<button class="pansel-close-button" mat-icon-button matTooltip="{{ 'PANEL.CLOSE' | translate }}" (click)="close()">
<mat-icon>arrow_back</mat-icon>
</button>
}
<div class="effects-container" fxFlex="100%" fxLayoutAlign="space-evenly none">
<div>

View File

@ -1,3 +1,12 @@
.prejoin-mode {
margin-top: 0px;
max-height: 100%;
min-height: 100%;
.effects-container {
padding: 0px;
}
}
.background-title {
color: var(--ov-text-surface-color);
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { BackgroundEffect, EffectType } from '../../../models/background-effect.model';
import { PanelType } from '../../../models/panel.model';
@ -16,6 +16,9 @@ import { VirtualBackgroundService } from '../../../services/virtual-background/v
standalone: false
})
export class BackgroundEffectsPanelComponent implements OnInit {
@Input() mode: 'prejoin' | 'meeting' = 'meeting';
@Output() onClose = new EventEmitter<void>();
backgroundSelectedId: string;
effectType = EffectType;
backgroundImages: BackgroundEffect[] = [];
@ -53,8 +56,12 @@ export class BackgroundEffectsPanelComponent implements OnInit {
}
close() {
if (this.mode === 'prejoin') {
this.onClose.emit();
} else {
this.panelService.togglePanel(PanelType.BACKGROUND_EFFECTS);
}
}
async applyBackground(effect: BackgroundEffect) {
await this.backgroundService.applyBackground(effect);

View File

@ -54,7 +54,7 @@
::ng-deep .lang-selector .expand-more-icon,
::ng-deep .lang-selector mat-icon {
color: var(--ov-secondary-action-color) !important;
color: var(--ov-text-surface-color) !important;
}
::ng-deep .lang-selector div,

View File

@ -1,19 +1,22 @@
<div class="prejoin-container" id="prejoin-container">
<!-- Top Language Toolbar -->
<div class="top-toolbar" *ngIf="!isMinimal">
<ov-lang-selector [compact]="false" class="language-selector" (onLangChanged)="onLangChanged.emit($event)"> </ov-lang-selector>
</div>
<!-- Loading State -->
<div *ngIf="isLoading" class="loading-overlay">
@if (isLoading) {
<div class="loading-overlay">
<div class="loading-content">
<mat-spinner [diameter]="40"></mat-spinner>
<span class="loading-text">{{ 'PREJOIN.PREPARING' | translate }}</span>
</div>
</div>
<!-- Main Content -->
<div *ngIf="!isLoading" class="prejoin-main">
<!-- 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>
} @else {
<!-- Main Content with Side Panel Layout -->
<div class="prejoin-content">
<!-- Main Card -->
<div class="prejoin-main">
<!-- Video Preview Section -->
<div class="video-preview-section">
<div class="video-preview-container">
@ -52,11 +55,26 @@
</ov-audio-devices-select>
</div>
</div>
<!-- Virtual Background Button -->
<div class="background-control" *ngIf="backgroundEffectEnabled">
<button
mat-icon-button
class="background-button"
(click)="toggleBackgroundPanel()"
[matTooltip]="'Virtual Backgrounds'"
>
<mat-icon>auto_fix_high</mat-icon>
</button>
</div>
</div>
</div>
</div>
</div>
@if (showBackgroundPanel) {
<ov-background-effects-panel [mode]="'prejoin'" (onClose)="closeBackgroundPanel()"> </ov-background-effects-panel>
} @else {
<!-- Configuration Section -->
<div class="configuration-section">
<!-- Participant Name Input -->
@ -79,11 +97,19 @@
<!-- Join Button -->
<div class="join-section">
<button mat-flat-button (click)="join()" class="join-button" [disabled]="showParticipantName && !participantName">
<button
mat-flat-button
(click)="join()"
class="join-button"
[disabled]="showParticipantName && !participantName"
>
<mat-icon class="join-icon">videocam</mat-icon>
{{ 'PREJOIN.JOIN' | translate }}
</button>
</div>
</div>
}
</div>
</div>
}
</div>

View File

@ -12,6 +12,42 @@
padding: 20px;
box-sizing: border-box;
position: relative;
transition: all 0.3s ease;
.prejoin-content {
display: flex;
justify-content: center;
width: 100%;
.prejoin-main {
max-width: 480px;
width: 100%;
}
}
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// Top Language Toolbar
.top-toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
justify-content: flex-end;
padding: 20px 24px;
background: transparent;
}
// Loading State
@ -51,50 +87,23 @@
max-width: 520px;
background: var(--ov-surface-color, #ffffff);
border-radius: var(--ov-surface-radius);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
display: flex;
flex-direction: column;
}
.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);
}
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
mat-icon {
color: var(--ov-text-secondary-color, #666) !important;
font-size: 18px;
}
}
}
// Video Preview Section
// Video Preview Section (moved to top with no padding)
.video-preview-section {
padding: 24px 24px 20px;
padding: 0;
.video-preview-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--ov-surface-radius);
aspect-ratio: 4/3; // Changed from 16/9 to 4/3 for taller video
border-radius: var(--ov-surface-radius) var(--ov-surface-radius) 0 0; // Only top corners rounded
overflow: hidden;
background: #000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
.video-frame {
width: 100%;
@ -111,6 +120,7 @@
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
}
}
}
@ -120,14 +130,54 @@
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;
display: flex;
justify-content: center;
align-items: flex-end;
.device-controls {
display: flex;
gap: 12px;
justify-content: center;
}
.background-control {
position: absolute;
bottom: 16px;
left: 16px;
.background-button {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: #333333;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
&:hover {
color: rgba(255, 255, 255, 1);
}
&:active {
transform: translateY(-1px);
transition: all 0.15s ease;
}
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
opacity: 0.9;
transition: opacity 0.2s ease;
}
&:hover mat-icon {
opacity: 1;
}
}
}
}
}
@ -135,7 +185,7 @@
// Configuration Section
.configuration-section {
padding: 0 24px 24px;
padding: 24px 24px 24px; // Added top padding since video has no padding
display: flex;
flex-direction: column;
gap: 20px;
@ -258,7 +308,7 @@
}
.video-preview-section {
padding: 16px 20px 12px;
padding: 0px 0px 12px;
.video-preview-container {
aspect-ratio: 4/3;
@ -270,8 +320,8 @@
gap: 16px;
}
.prejoin-header {
padding: 12px 16px 0;
.top-toolbar {
padding: 16px 20px;
}
}
@ -280,10 +330,6 @@
padding: 12px;
}
.video-preview-section {
padding: 12px 16px 8px;
}
.configuration-section {
padding: 0 16px 16px;
}
@ -300,16 +346,20 @@
}
}
}
.top-toolbar {
padding: 12px 16px;
}
}
@media (max-height: 640px) {
.prejoin-container {
align-items: flex-start;
padding-top: 20px;
padding-top: 60px; // Add space for top toolbar
}
.video-preview-section .video-preview-container {
aspect-ratio: 16/9;
aspect-ratio: 4/3; // Keep the taller aspect ratio even on small screens
}
}

View File

@ -19,8 +19,6 @@ import { TranslateService } from '../../services/translate/translate.service';
import { LocalTrack } from 'livekit-client';
import { CustomDevice } from '../../models/device.model';
import { LangOption } from '../../models/lang.model';
import { VirtualBackgroundService } from '../../services/virtual-background/virtual-background.service';
import { BackgroundEffect } from '../../models/background-effect.model';
/**
* @internal
@ -44,7 +42,6 @@ export class PreJoinComponent implements OnInit, OnDestroy {
@Output() onReadyToJoin = new EventEmitter<any>();
_error: string | undefined;
windowSize: number;
isLoading = true;
participantName: string | undefined = '';
@ -59,9 +56,8 @@ export class PreJoinComponent implements OnInit, OnDestroy {
showParticipantName: boolean = true;
// Future feature preparation
backgroundEffectEnabled: boolean = false;
availableBackgroundEffects: BackgroundEffect[] = [];
selectedBackgroundEffect: BackgroundEffect | undefined;
backgroundEffectEnabled: boolean = true; // Enable virtual backgrounds by default
showBackgroundPanel: boolean = false;
videoTrack: LocalTrack | undefined;
audioTrack: LocalTrack | undefined;
@ -81,11 +77,9 @@ export class PreJoinComponent implements OnInit, OnDestroy {
private cdkSrv: CdkOverlayService,
private openviduService: OpenViduService,
private translateService: TranslateService,
private virtualBackgroundService: VirtualBackgroundService,
private changeDetector: ChangeDetectorRef
) {
this.log = this.loggerSrv.get('PreJoinComponent');
this.availableBackgroundEffects = this.virtualBackgroundService.getBackgrounds();
}
async ngOnInit() {
@ -221,14 +215,19 @@ export class PreJoinComponent implements OnInit, OnDestroy {
}
/**
* Future method for background effects
* @param effect - The background effect to apply
* Toggle virtual background panel visibility
*/
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]);
toggleBackgroundPanel() {
this.showBackgroundPanel = !this.showBackgroundPanel;
this.changeDetector.markForCheck();
}
/**
* Close virtual background panel
*/
closeBackgroundPanel() {
this.showBackgroundPanel = false;
this.changeDetector.markForCheck();
}
/**

View File

@ -10,7 +10,8 @@
&.compact {
.unified-device-button {
display: flex;
background: var(--ov-secondary-action-color);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;

View File

@ -1,32 +1,25 @@
<div class="language-selector-container">
@if (compact) {
<!-- Compact version (icon only) -->
<button
*ngIf="compact"
mat-icon-button
[matMenuTriggerFor]="menu"
class="compact-lang-button"
[matTooltip]="'Change language'"
>
<button mat-icon-button [matMenuTriggerFor]="langMenu" class="compact-lang-button" [matTooltip]="'Change language'" disableRipple>
<mat-icon>translate</mat-icon>
</button>
} @else {
<!-- Full version (with text) -->
<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>
<button mat-flat-button [matMenuTriggerFor]="langMenu" class="full-lang-button">
<!-- <mat-icon class="lang-icon">translate</mat-icon> -->
<span class="lang-name">
{{ langSelected?.name }}
<mat-icon class="expand-icon">expand_more</mat-icon>
</span>
</button>
}
<!-- Language Menu -->
<mat-menu #menu="matMenu" class="language-menu">
<mat-menu #langMenu="matMenu" class="language-menu">
@for (lang of languages; track lang.lang) {
<button
mat-menu-item
*ngFor="let lang of languages"
(click)="onLangSelected(lang.lang)"
[attr.id]="'lang-opt-' + lang.lang"
[class.selected]="langSelected?.lang === lang.lang"
@ -35,5 +28,6 @@
<mat-icon *ngIf="langSelected?.lang === lang.lang" class="check-icon">check</mat-icon>
<span class="lang-option-name">{{ lang.name }}</span>
</button>
}
</mat-menu>
</div>

View File

@ -12,12 +12,6 @@
transition: all 0.2s ease;
color: var(--ov-text-secondary-color, #666);
&:hover {
background: rgba(255, 255, 255, 1);
border-color: var(--ov-primary-action-color, #4285f4);
transform: scale(1.02);
}
mat-icon {
font-size: 18px;
width: 18px;
@ -46,12 +40,14 @@
font-size: 18px;
width: 18px;
height: 18px;
color: var(--ov-text-secondary-color, #666);
color: var(--ov-text-surface-color, #666);
}
.lang-name {
font-size: 14px;
font-weight: 500;
display: inline-block !important;
color: var(--ov-text-surface-color) !important;
}
.expand-icon {
@ -62,21 +58,19 @@
transition: transform 0.2s ease;
}
&[aria-expanded="true"] .expand-icon {
&[aria-expanded='true'] .expand-icon {
transform: rotate(180deg);
}
}
}
}
::ng-deep .language-menu {
.mat-mdc-menu-panel {
::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;
@ -86,6 +80,7 @@
transition: background-color 0.2s ease;
font-size: 14px;
min-height: 48px;
color: var(--ov-text-surface-color);
&:hover {
background-color: var(--ov-hover-color, #f5f5f5);
@ -109,11 +104,9 @@
.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

@ -2,12 +2,12 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { BackgroundEffect, EffectType } from '../../models/background-effect.model';
import { ParticipantService } from '../participant/participant.service';
import { OpenViduService } from '../openvidu/openvidu.service';
import { StorageService } from '../storage/storage.service';
import { LocalTrack } from 'livekit-client';
import { LocalVideoTrack, Track } from 'livekit-client';
import { BackgroundBlur, BackgroundOptions, ProcessorWrapper, VirtualBackground } from '@livekit/track-processors';
import { LoggerService } from '../logger/logger.service';
import { ILogger } from '../../models/logger.model';
import { ParticipantTrackPublication } from '../../models/participant.model';
/**
* @internal
@ -49,6 +49,7 @@ export class VirtualBackgroundService {
private log: ILogger;
constructor(
private participantService: ParticipantService,
private openviduService: OpenViduService,
private storageService: StorageService,
private loggerSrv: LoggerService
) {
@ -79,11 +80,12 @@ export class VirtualBackgroundService {
// If the background is already applied, do nothing
if (this.backgroundIsAlreadyApplied(bg.id)) return;
const cameraTracks = this.getCameraTracks();
if (!cameraTracks) {
this.log.e('No camera tracks found. Cannot apply background.');
const cameraTrack = this.getCameraTrack();
if (!cameraTrack) {
this.log.e('No camera track found. Cannot apply background.');
return;
}
try {
// If no effect is selected, remove the background
if (bg.type === EffectType.NONE) {
@ -91,8 +93,7 @@ export class VirtualBackgroundService {
return;
}
const localTrack = cameraTracks[0].track as LocalTrack;
const currentProcessor = localTrack.getProcessor() as ProcessorWrapper<BackgroundOptions>;
const currentProcessor = cameraTrack.getProcessor() as ProcessorWrapper<BackgroundOptions>;
// Check if the background is the same type as the previous one
if (this.hasSameTypeAsPreviousOne(bg.type) && currentProcessor) {
@ -104,7 +105,7 @@ export class VirtualBackgroundService {
this.log.e('No processor found for the background effect.');
return;
}
await this.applyProcessorToCameraTracks(cameraTracks, newProcessor);
await this.applyProcessorToCameraTrack(cameraTrack, newProcessor);
}
this.storageService.setBackground(bg.id);
@ -128,15 +129,14 @@ export class VirtualBackgroundService {
async removeBackground() {
if (this.isBackgroundApplied()) {
this.backgroundIdSelected.next('no_effect');
const tracks = this.participantService.getLocalParticipant()?.tracks;
const promises = tracks?.map(async (t) => {
const cameraTrack = this.getCameraTrack();
if (cameraTrack) {
try {
await (t.track as LocalTrack).stopProcessor();
await cameraTrack.stopProcessor();
} catch (e) {
this.log.w('Error stopping processor:', e);
}
});
await Promise.all(promises || []);
}
this.storageService.removeBackground();
}
}
@ -160,26 +160,41 @@ export class VirtualBackgroundService {
return undefined;
}
private async applyProcessorToCameraTracks(
cameraTracks: ParticipantTrackPublication[],
processor: ProcessorWrapper<BackgroundOptions>
) {
const promises = cameraTracks.map((track) => {
return (track.track as LocalTrack).setProcessor(processor);
});
/**
* Gets the camera track from either the published tracks (if in room) or local tracks (if in prejoin)
* @returns The camera LocalTrack or undefined if not found
* @private
*/
private getCameraTrack(): LocalVideoTrack | undefined {
// First, try to get from published tracks (when in room)
if (this.openviduService.isRoomConnected()) {
const localParticipant = this.participantService.getLocalParticipant();
const cameraTrackPublication = localParticipant?.cameraTracks?.[0];
if (cameraTrackPublication?.track) {
return cameraTrackPublication.track as LocalVideoTrack;
}
}
await Promise.all(promises || []);
// Fallback to local tracks (when in prejoin or tracks not yet published)
const localTracks = this.openviduService.getLocalTracks();
const cameraTrack = localTracks.find((track) => track.kind === Track.Kind.Video);
return cameraTrack as LocalVideoTrack | undefined;
}
/**
* Applies a background processor to the camera track
* @param cameraTrack The camera track to apply the processor to
* @param processor The background processor to apply
* @private
*/
private async applyProcessorToCameraTrack(cameraTrack: LocalVideoTrack, processor: ProcessorWrapper<BackgroundOptions>): Promise<void> {
await cameraTrack.setProcessor(processor);
}
private backgroundIsAlreadyApplied(backgroundId: string): boolean {
return backgroundId === this.backgroundIdSelected.getValue();
}
private getCameraTracks(): ParticipantTrackPublication[] | undefined {
const localParticipant = this.participantService.getLocalParticipant();
return localParticipant?.cameraTracks;
}
/**
* Replaces the current background effect with a new one by updating the processor options.
*