Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,137 @@
# Screen Share Domain
Manages screen sharing sessions, source selection (Electron), quality presets, and the viewer/workspace UI. Like `voice-connection`, the actual WebRTC track distribution lives in `infrastructure/realtime`; this domain provides the application-facing API and UI components.
## Module map
```
screen-share/
├── application/
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
├── domain/
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel)
├── feature/
│ ├── screen-share-viewer/ Single-stream video player with fullscreen + volume
│ └── screen-share-workspace/ Multi-stream grid workspace
│ ├── screen-share-workspace.component.ts Grid layout, featured/thumbnail streams, mini-window mode
│ ├── screen-share-stream-tile.component.ts Individual stream tile with fullscreen/volume controls
│ ├── screen-share-playback.service.ts Per-user mute/volume state for screen share audio
│ └── screen-share-workspace.models.ts ScreenShareWorkspaceStreamItem
└── index.ts Barrel exports
```
## Service relationships
```mermaid
graph TD
SSF[ScreenShareFacade]
Picker[ScreenShareSourcePickerService]
RSF[RealtimeSessionFacade]
Config[screen-share.config]
Viewer[ScreenShareViewerComponent]
Workspace[ScreenShareWorkspaceComponent]
Tile[ScreenShareStreamTileComponent]
Playback[ScreenSharePlaybackService]
SSF --> RSF
Viewer --> SSF
Workspace --> SSF
Workspace --> Playback
Workspace --> Tile
Picker --> Config
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade" _blank
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker" _blank
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace" _blank
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile" _blank
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state" _blank
click Config "domain/screen-share.config.ts" "Quality presets" _blank
```
## Starting a screen share
```mermaid
sequenceDiagram
participant User
participant Controls as VoiceControls
participant Facade as ScreenShareFacade
participant Realtime as RealtimeSessionFacade
participant Picker as SourcePickerService
User->>Controls: Click "Share Screen"
alt Electron
Controls->>Picker: open(sources)
Picker-->>Controls: selected source + includeSystemAudio
end
Controls->>Facade: startScreenShare(options)
Facade->>Realtime: startScreenShare(options)
Note over Realtime: Captures screen via platform strategy
Note over Realtime: Waits for SCREEN_SHARE_REQUEST from viewers
Realtime-->>Facade: MediaStream
User->>Controls: Click "Stop"
Controls->>Facade: stopScreenShare()
Facade->>Realtime: stopScreenShare()
```
## Source picker (Electron)
`ScreenShareSourcePickerService` manages a Promise-based flow for Electron desktop capture. `open()` sets a signal with the available sources, and the UI renders a picker dialog. When the user selects a source, `confirm(sourceId, includeSystemAudio)` resolves the Promise. `cancel()` rejects with an `AbortError`.
Sources are classified as either `screen` or `window` based on the source ID prefix or name. The `includeSystemAudio` preference is persisted to voice settings storage.
## Quality presets
Screen share quality is configured through presets defined in the shared kernel:
| Preset | Resolution | Framerate |
|---|---|---|
| `low` | Reduced | Lower FPS |
| `balanced` | Medium | Medium FPS |
| `high` | Full | High FPS |
The quality dialog can be shown before each share (`askScreenShareQuality` setting) or skipped to use the last chosen preset.
## Viewer component
`ScreenShareViewerComponent` is a single-stream video player. It supports:
- Fullscreen toggle (browser Fullscreen API with CSS fallback)
- Volume control for remote streams (delegated to `VoicePlaybackService`)
- Local shares are always muted to avoid feedback
- Focus events from other components via a `viewer:focus` custom DOM event
- Auto-stop when the watched user stops sharing or the stream's video tracks end
## Workspace component
`ScreenShareWorkspaceComponent` is the multi-stream grid view inside the voice workspace panel. It handles:
- Listing all active screen shares (local + remote) sorted with remote first
- Featured/widescreen mode for a single focused stream with thumbnail sidebar
- Mini-window mode (draggable, position-clamped to viewport)
- Auto-hide header chrome in widescreen mode (2.2 s timeout, revealed on pointer move)
- On-demand remote stream requests via `syncRemoteScreenShareRequests`
- Per-stream volume and mute via `ScreenSharePlaybackService`
- Voice controls (mute, deafen, disconnect, share toggle) integrated into the workspace header
```mermaid
stateDiagram-v2
[*] --> Hidden
Hidden --> Expanded: open()
Expanded --> GridView: multiple shares, no focus
Expanded --> WidescreenView: single share or focused stream
WidescreenView --> GridView: showAllStreams()
GridView --> WidescreenView: focusShare(peerKey)
Expanded --> Minimized: minimize()
Minimized --> Expanded: restore()
Expanded --> Hidden: close()
Minimized --> Hidden: close()
```

View File

@@ -0,0 +1,134 @@
import {
Injectable,
computed,
signal
} from '@angular/core';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
export type ScreenShareSourceKind = 'screen' | 'window';
export interface ScreenShareSourceOption {
id: string;
kind: ScreenShareSourceKind;
name: string;
thumbnail: string;
}
export interface ScreenShareSourceSelection {
includeSystemAudio: boolean;
source: ScreenShareSourceOption;
}
interface ScreenShareSourcePickerRequest {
includeSystemAudio: boolean;
sources: readonly ScreenShareSourceOption[];
}
@Injectable({ providedIn: 'root' })
export class ScreenShareSourcePickerService {
readonly request = computed(() => this._request());
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
private pendingReject: ((reason?: unknown) => void) | null = null;
open(
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
): Promise<ScreenShareSourceSelection> {
if (sources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
this.cancelPendingRequest();
const normalizedSources = sources.map((source) => {
const kind = this.getSourceKind(source);
return {
...source,
kind,
name: this.getSourceDisplayName(source.name, kind)
};
});
this._request.set({
includeSystemAudio: initialIncludeSystemAudio,
sources: normalizedSources
});
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
this.pendingResolve = resolve;
this.pendingReject = reject;
});
}
confirm(sourceId: string, includeSystemAudio: boolean): void {
const activeRequest = this._request();
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
const resolve = this.pendingResolve;
if (!source || !resolve) {
return;
}
this.clearPendingRequest();
saveVoiceSettingsToStorage({ includeSystemAudio });
resolve({
includeSystemAudio,
source
});
}
cancel(): void {
this.cancelPendingRequest();
}
private cancelPendingRequest(): void {
const reject = this.pendingReject;
this.clearPendingRequest();
if (reject) {
reject(this.createAbortError());
}
}
private clearPendingRequest(): void {
this._request.set(null);
this.pendingResolve = null;
this.pendingReject = null;
}
private getSourceKind(
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
): ScreenShareSourceKind {
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
? 'screen'
: 'window';
}
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
const trimmedName = name.trim();
if (trimmedName) {
return trimmedName;
}
return kind === 'screen' ? 'Entire screen' : 'Window';
}
private createAbortError(): Error {
if (typeof DOMException !== 'undefined') {
return new DOMException('The user aborted a request.', 'AbortError');
}
const error = new Error('The user aborted a request.');
error.name = 'AbortError';
return error;
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareStartOptions } from '../domain/screen-share.config';
@Injectable({ providedIn: 'root' })
export class ScreenShareFacade {
readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing;
readonly screenStream = inject(RealtimeSessionFacade).screenStream;
readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed;
readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput;
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
private readonly realtime = inject(RealtimeSessionFacade);
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.realtime.getRemoteScreenShareStream(peerId);
}
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.realtime.startScreenShare(options);
}
stopScreenShare(): void {
this.realtime.stopScreenShare();
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
this.realtime.syncRemoteScreenShareRequests(peerIds, enabled);
}
}

View File

@@ -0,0 +1,21 @@
import {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQualityPreset,
type ScreenShareStartOptions,
type ScreenShareQuality
} from '../../../shared-kernel';
export {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQualityPreset,
type ScreenShareStartOptions,
type ScreenShareQuality
};

View File

@@ -0,0 +1,102 @@
<div
class="relative bg-black rounded-lg overflow-hidden"
[class.fixed]="isFullscreen()"
[class.inset-0]="isFullscreen()"
[class.z-50]="isFullscreen()"
[class.hidden]="!hasStream()"
>
<!-- Video Element -->
<video
#screenVideo
autoplay
playsinline
class="w-full h-full object-contain"
[class.max-h-[400px]]="!isFullscreen()"
></video>
<!-- Overlay Controls -->
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<ng-icon
name="lucideMonitor"
class="w-4 h-4"
/>
@if (activeScreenSharer()) {
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
}
</div>
<div class="flex items-center gap-3">
<!-- Viewer volume -->
<div class="flex items-center gap-2 text-white">
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
<input
type="range"
min="0"
max="200"
[value]="screenVolume()"
(input)="onScreenVolumeChange($event)"
class="w-32 accent-white"
/>
</div>
<button
(click)="toggleFullscreen()"
type="button"
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
>
@if (isFullscreen()) {
<ng-icon
name="lucideMinimize"
class="w-4 h-4 text-white"
/>
} @else {
<ng-icon
name="lucideMaximize"
class="w-4 h-4 text-white"
/>
}
</button>
@if (isLocalShare()) {
<button
(click)="stopSharing()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
} @else {
<button
(click)="stopWatching()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
}
</div>
</div>
</div>
<!-- No Stream Placeholder -->
@if (!hasStream()) {
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground">
<ng-icon
name="lucideMonitor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>Waiting for screen share...</p>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,279 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import {
Component,
inject,
signal,
ElementRef,
ViewChild,
OnDestroy,
effect
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor
} from '@ng-icons/lucide';
import { ScreenShareFacade } from '../../application/screen-share.facade';
import { selectOnlineUsers } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { DEFAULT_VOLUME } from '../../../../core/constants';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
@Component({
selector: 'app-screen-share-viewer',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor
})
],
templateUrl: './screen-share-viewer.component.html'
})
/**
* Displays a local or remote screen-share stream in a video player.
* Supports fullscreen toggling, volume control, and viewer focus events.
*/
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
private readonly screenShareService = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
private remoteStreamSub: Subscription | null = null;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeScreenSharer = signal<User | null>(null);
// Track the userId we're currently watching (for detecting when they stop sharing)
private watchingUserId = signal<string | null>(null);
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
screenVolume = signal(DEFAULT_VOLUME);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
try {
const userId = evt.detail?.userId;
if (!userId)
return;
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) {
this.setRemoteStream(stream, user);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.hasStream.set(true);
this.activeScreenSharer.set(null);
this.watchingUserId.set(userId);
this.screenVolume.set(this.voicePlayback.getUserVolume(userId));
this.isLocalShare.set(false);
}
}
} catch (_error) {
// Failed to focus viewer on user stream
}
};
constructor() {
// React to screen share stream changes
effect(() => {
const screenStream = this.screenShareService.screenStream();
if (screenStream && this.videoRef) {
// Local share: always mute to avoid audio feedback
this.videoRef.nativeElement.srcObject = screenStream;
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.isLocalShare.set(true);
this.hasStream.set(true);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
this.isLocalShare.set(false);
this.hasStream.set(false);
}
});
// Watch for when the user we're watching stops sharing
effect(() => {
const watchingId = this.watchingUserId();
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
// Only check if we're actually watching a remote stream
if (!watchingId || !isWatchingRemote)
return;
const users = this.onlineUsers();
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
this.stopWatching();
return;
}
// Also check if the stream's video tracks are still available
const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching
this.stopWatching();
}
});
// Subscribe to remote streams with video (screen shares)
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
// This subscription is kept for potential future use (e.g., tracking available streams)
this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
if (peerId !== this.watchingUserId() || this.isLocalShare()) {
return;
}
const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
if (!hasActiveVideo) {
this.stopWatching();
}
});
// Listen for focus events dispatched by other components
window.addEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
ngOnDestroy(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
}
// Cleanup subscription
this.remoteStreamSub?.unsubscribe();
// Remove event listener
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
/** Toggle between fullscreen and windowed display. */
toggleFullscreen(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
/** Enter fullscreen mode, requesting browser fullscreen if available. */
enterFullscreen(): void {
this.isFullscreen.set(true);
// Request browser fullscreen if available
if (this.videoRef?.nativeElement.requestFullscreen) {
this.videoRef.nativeElement.requestFullscreen().catch(() => {
// Fallback to CSS fullscreen
});
}
}
/** Exit fullscreen mode. */
exitFullscreen(): void {
this.isFullscreen.set(false);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
/** Stop the local screen share and reset viewer state. */
stopSharing(): void {
this.screenShareService.stopScreenShare();
this.activeScreenSharer.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
}
/** Stop watching a remote stream and reset the viewer. */
// Stop watching a remote stream (for viewers)
stopWatching(): void {
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
}
this.activeScreenSharer.set(null);
this.watchingUserId.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
if (this.isFullscreen()) {
this.exitFullscreen();
}
}
/** Attach and play a remote peer's screen-share stream. */
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.watchingUserId.set(user.id || user.oderId || null);
this.isLocalShare.set(false);
this.screenVolume.set(this.voicePlayback.getUserVolume(user.id || user.oderId || ''));
if (this.videoRef) {
const el = this.videoRef.nativeElement;
el.srcObject = stream;
// Keep the viewer muted so screen-share audio only plays once via VoicePlaybackService.
el.muted = true;
el.volume = 0;
el.play().catch(() => {});
this.hasStream.set(true);
}
}
/** Attach and play the local user's screen-share stream (always muted). */
// Called when local user starts sharing
setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.isLocalShare.set(true);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
// Always mute local share playback
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.hasStream.set(true);
}
}
/** Handle volume slider changes, applying only to remote streams. */
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(200, parseInt(input.value, 10)));
this.screenVolume.set(val);
if (!this.isLocalShare()) {
const userId = this.watchingUserId();
if (userId) {
this.voicePlayback.setUserVolume(userId, val);
}
}
}
}

View File

@@ -0,0 +1,77 @@
import { Injectable, signal } from '@angular/core';
interface ScreenSharePlaybackSettings {
muted: boolean;
volume: number;
}
const DEFAULT_SETTINGS: ScreenSharePlaybackSettings = {
muted: false,
volume: 100
};
@Injectable({ providedIn: 'root' })
export class ScreenSharePlaybackService {
private readonly _settings = signal<ReadonlyMap<string, ScreenSharePlaybackSettings>>(new Map());
settings(): ReadonlyMap<string, ScreenSharePlaybackSettings> {
return this._settings();
}
getUserVolume(peerId: string): number {
return this._settings().get(peerId)?.volume ?? DEFAULT_SETTINGS.volume;
}
isUserMuted(peerId: string): boolean {
return this._settings().get(peerId)?.muted ?? DEFAULT_SETTINGS.muted;
}
setUserVolume(peerId: string, volume: number): void {
const nextVolume = Math.max(0, Math.min(100, volume));
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
this._settings.update((settings) => {
const next = new Map(settings);
next.set(peerId, {
...current,
muted: nextVolume === 0 ? current.muted : false,
volume: nextVolume
});
return next;
});
}
setUserMuted(peerId: string, muted: boolean): void {
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
this._settings.update((settings) => {
const next = new Map(settings);
next.set(peerId, {
...current,
muted
});
return next;
});
}
resetUser(peerId: string): void {
this._settings.update((settings) => {
if (!settings.has(peerId)) {
return settings;
}
const next = new Map(settings);
next.delete(peerId);
return next;
});
}
teardownAll(): void {
// Screen-share audio is played directly by the video element.
}
}

View File

@@ -0,0 +1,220 @@
<div
#tileRoot
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
tabindex="0"
role="button"
[attr.aria-label]="mini() ? 'Focus ' + displayName() + ' stream' : 'Open ' + displayName() + ' stream in widescreen mode'"
[attr.title]="canToggleFullscreen() ? (isFullscreen() ? 'Double-click to exit fullscreen' : 'Double-click for fullscreen') : null"
[ngClass]="{
'ring-2 ring-primary/70': focused() && !immersive() && !mini() && !isFullscreen(),
'min-h-[24rem] rounded-[1.75rem] border border-white/10 shadow-2xl': featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-[1.75rem] border border-white/10 shadow-2xl': !featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-2xl border border-white/10 shadow-2xl': compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-2xl border border-white/10 shadow-xl': mini() && !isFullscreen(),
'shadow-none': immersive() || isFullscreen()
}"
(click)="requestFocus()"
(dblclick)="onTileDoubleClick($event)"
(mousemove)="onTilePointerMove()"
(keydown.enter)="requestFocus()"
(keydown.space)="requestFocus(); $event.preventDefault()"
>
<video
#streamVideo
autoplay
playsinline
class="absolute inset-0 h-full w-full bg-black object-contain"
></video>
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/70 via-black/10 to-black/80"></div>
@if (isFullscreen()) {
<div
class="pointer-events-none absolute inset-x-3 top-3 z-20 transition-all duration-300 sm:inset-x-4 sm:top-4"
[class.opacity-0]="!showFullscreenHeader()"
[class.translate-y-[-12px]]="!showFullscreenHeader()"
>
<div
class="pointer-events-auto flex items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
[class.pointer-events-none]="!showFullscreenHeader()"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate text-sm font-semibold text-white sm:text-base">{{ displayName() }}</p>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary"> Live </span>
</div>
<p class="mt-1 text-xs text-white/60">
{{ item().isLocal ? 'Local preview in fullscreen' : 'Fullscreen stream view' }}
</p>
</div>
</div>
@if (!item().isLocal) {
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-4 w-4"
/>
</button>
}
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
title="Exit fullscreen"
(click)="exitFullscreen($event)"
>
<ng-icon
name="lucideMinimize"
class="h-4 w-4"
/>
</button>
</div>
</div>
}
@if (mini()) {
<div class="absolute inset-x-0 bottom-0 p-2">
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">
<div class="flex items-center gap-2">
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-white">{{ displayName() }}</p>
<p class="text-[10px] uppercase tracking-[0.16em] text-white/60">Live stream</p>
</div>
</div>
</div>
</div>
} @else if (!immersive()) {
<div
class="absolute left-4 top-4 flex items-center gap-3 bg-black/50 backdrop-blur-md"
[ngClass]="compact() ? 'max-w-[calc(100%-5rem)] rounded-xl px-2.5 py-2' : 'max-w-[calc(100%-8rem)] rounded-full px-3 py-2'"
>
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0">
<p
class="truncate font-semibold text-white"
[class.text-xs]="compact()"
[class.text-sm]="!compact()"
>
{{ displayName() }}
</p>
<p
class="flex items-center gap-1 uppercase text-white/65"
[class.text-[10px]]="compact()"
[class.text-[11px]]="!compact()"
[class.tracking-[0.18em]]="compact()"
[class.tracking-[0.24em]]="!compact()"
>
<ng-icon
name="lucideMonitor"
class="h-3 w-3"
/>
Live
</p>
</div>
</div>
<div class="absolute right-4 top-4 flex items-center gap-2 opacity-100 transition md:opacity-0 md:group-hover:opacity-100">
<button
type="button"
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
[class.h-8]="compact()"
[class.w-8]="compact()"
[class.h-10]="!compact()"
[class.w-10]="!compact()"
[title]="focused() ? 'Viewing in widescreen' : 'View in widescreen'"
(click)="requestFocus(); $event.stopPropagation()"
>
<ng-icon
name="lucideMaximize"
[class.h-3.5]="compact()"
[class.w-3.5]="compact()"
[class.h-4]="!compact()"
[class.w-4]="!compact()"
/>
</button>
@if (!item().isLocal) {
<button
type="button"
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
[class.h-8]="compact()"
[class.w-8]="compact()"
[class.h-10]="!compact()"
[class.w-10]="!compact()"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
[class.h-3.5]="compact()"
[class.w-3.5]="compact()"
[class.h-4]="!compact()"
[class.w-4]="!compact()"
/>
</button>
}
</div>
<div class="absolute inset-x-0 bottom-0 p-4">
@if (item().isLocal) {
@if (!compact()) {
<div class="rounded-2xl bg-black/50 px-4 py-3 text-xs text-white/75 backdrop-blur-md">
Your preview stays muted locally to avoid audio feedback.
</div>
}
} @else {
@if (compact()) {
<div class="rounded-xl bg-black/50 px-3 py-2 text-[11px] text-white/80 backdrop-blur-md">
{{ muted() ? 'Muted' : volume() + '% audio' }}
</div>
} @else {
<div class="rounded-2xl bg-black/50 px-4 py-3 backdrop-blur-md">
<div class="mb-2 flex items-center justify-between text-xs text-white/80">
<span class="flex items-center gap-2 font-medium">
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-3.5 w-3.5"
/>
Stream audio
</span>
<span>{{ muted() ? 'Muted' : volume() + '%' }}</span>
</div>
<input
type="range"
min="0"
max="100"
[value]="volume()"
class="w-full accent-primary"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
</div>
}
}
</div>
}
</div>

View File

@@ -0,0 +1,263 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
effect,
HostListener,
inject,
input,
OnDestroy,
output,
signal,
viewChild
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMaximize,
lucideMinimize,
lucideMonitor,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared';
import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
@Component({
selector: 'app-screen-share-stream-tile',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideMaximize,
lucideMinimize,
lucideMonitor,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './screen-share-stream-tile.component.html',
host: {
class: 'block h-full'
}
})
export class ScreenShareStreamTileComponent implements OnDestroy {
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<ScreenShareWorkspaceStreamItem>();
readonly focused = input(false);
readonly featured = input(false);
readonly compact = input(false);
readonly mini = input(false);
readonly immersive = input(false);
readonly focusRequested = output<string>();
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
readonly isFullscreen = signal(false);
readonly showFullscreenHeader = signal(true);
readonly volume = signal(100);
readonly muted = signal(false);
constructor() {
effect(() => {
const ref = this.videoRef();
const item = this.item();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (video.srcObject !== item.stream) {
video.srcObject = item.stream;
}
void video.play().catch(() => {});
});
effect(
() => {
this.screenSharePlayback.settings();
const item = this.item();
if (item.isLocal) {
this.volume.set(0);
this.muted.set(false);
return;
}
this.volume.set(this.screenSharePlayback.getUserVolume(item.peerKey));
this.muted.set(this.screenSharePlayback.isUserMuted(item.peerKey));
},
{ allowSignalWrites: true }
);
effect(() => {
const ref = this.videoRef();
const item = this.item();
const muted = this.muted();
const volume = this.volume();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (item.isLocal) {
video.muted = true;
video.volume = 0;
return;
}
video.muted = muted;
video.volume = Math.max(0, Math.min(1, volume / 100));
void video.play().catch(() => {});
});
}
@HostListener('document:fullscreenchange')
onFullscreenChange(): void {
const tile = this.tileRef()?.nativeElement;
const isFullscreen = !!tile && document.fullscreenElement === tile;
this.isFullscreen.set(isFullscreen);
if (isFullscreen) {
this.revealFullscreenHeader();
return;
}
this.clearFullscreenHeaderHideTimeout();
this.showFullscreenHeader.set(true);
}
ngOnDestroy(): void {
this.clearFullscreenHeaderHideTimeout();
const tile = this.tileRef()?.nativeElement;
if (tile && document.fullscreenElement === tile) {
void document.exitFullscreen().catch(() => {});
}
}
canToggleFullscreen(): boolean {
return !this.mini() && !this.compact() && (this.immersive() || this.focused());
}
onTilePointerMove(): void {
if (!this.isFullscreen()) {
return;
}
this.revealFullscreenHeader();
}
async onTileDoubleClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
if (!this.canToggleFullscreen()) {
return;
}
const tile = this.tileRef()?.nativeElement;
if (!tile || !tile.requestFullscreen) {
return;
}
if (document.fullscreenElement === tile) {
await document.exitFullscreen().catch(() => {});
return;
}
await tile.requestFullscreen().catch(() => {});
}
async exitFullscreen(event?: Event): Promise<void> {
event?.preventDefault();
event?.stopPropagation();
if (!this.isFullscreen()) {
return;
}
await document.exitFullscreen().catch(() => {});
}
requestFocus(): void {
this.focusRequested.emit(this.item().peerKey);
}
toggleMuted(): void {
const item = this.item();
if (item.isLocal) {
return;
}
const nextMuted = !this.muted();
this.muted.set(nextMuted);
this.screenSharePlayback.setUserMuted(item.peerKey, nextMuted);
}
updateVolume(event: Event): void {
const item = this.item();
if (item.isLocal) {
return;
}
const input = event.target as HTMLInputElement;
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
this.volume.set(nextVolume);
this.screenSharePlayback.setUserVolume(item.peerKey, nextVolume);
if (nextVolume > 0 && this.muted()) {
this.muted.set(false);
this.screenSharePlayback.setUserMuted(item.peerKey, false);
}
}
displayName(): string {
return this.item().isLocal ? 'You' : this.item().user.displayName;
}
private scheduleFullscreenHeaderHide(): void {
this.clearFullscreenHeaderHideTimeout();
this.fullscreenHeaderHideTimeoutId = setTimeout(() => {
this.showFullscreenHeader.set(false);
this.fullscreenHeaderHideTimeoutId = null;
}, 2200);
}
private revealFullscreenHeader(): void {
this.showFullscreenHeader.set(true);
this.scheduleFullscreenHeaderHide();
}
private clearFullscreenHeaderHideTimeout(): void {
if (this.fullscreenHeaderHideTimeoutId === null) {
return;
}
clearTimeout(this.fullscreenHeaderHideTimeoutId);
this.fullscreenHeaderHideTimeoutId = null;
}
}

View File

@@ -0,0 +1,365 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div class="absolute inset-0">
@if (showExpanded()) {
<section
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
(mouseenter)="onWorkspacePointerMove()"
(mousemove)="onWorkspacePointerMove()"
>
<div class="flex h-full min-h-0 flex-col">
<div class="relative flex-1 min-h-0 overflow-hidden">
<div
class="pointer-events-none absolute inset-x-3 top-3 z-10 transition-all duration-300 sm:inset-x-4 sm:top-4"
[class.opacity-0]="!showWorkspaceHeader()"
[class.translate-y-[-12px]]="!showWorkspaceHeader()"
>
<div
class="pointer-events-auto flex flex-wrap items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
[class.pointer-events-none]="!showWorkspaceHeader()"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-sm font-semibold text-white sm:text-base">{{ connectedVoiceChannelName() }}</h2>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
Streams
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-white/65">
<span>{{ serverName() }}</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ connectedVoiceUsers().length }} in voice</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }}</span>
</div>
</div>
</div>
@if (connectedVoiceUsers().length > 0) {
<div class="hidden items-center gap-2 lg:flex">
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
<app-user-avatar
[name]="participant.displayName"
[avatarUrl]="participant.avatarUrl"
size="xs"
[ringClass]="'ring-2 ring-white/10'"
/>
}
@if (connectedVoiceUsers().length > 4) {
<div class="rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-white/70">
+{{ connectedVoiceUsers().length - 4 }}
</div>
}
</div>
}
@if (isWidescreenMode() && widescreenShare()) {
<div class="flex min-w-0 items-center gap-2 rounded-2xl border border-white/10 bg-black/35 px-2.5 py-2 text-white/85">
<app-user-avatar
[name]="focusedShareTitle()"
[avatarUrl]="widescreenShare()!.user.avatarUrl"
size="xs"
/>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-white">{{ focusedShareTitle() }}</p>
<p class="text-[10px] uppercase tracking-[0.18em] text-white/55">
{{ widescreenShare()!.isLocal ? 'Local preview' : 'Focused stream' }}
</p>
</div>
@if (focusedAudioShare()) {
<div class="mx-1 hidden h-6 w-px bg-white/10 sm:block"></div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="focusedShareMuted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleFocusedShareMuted()"
>
<ng-icon
[name]="focusedShareMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-3.5 w-3.5"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="focusedShareVolume()"
class="h-1.5 w-20 accent-primary sm:w-24"
(input)="updateFocusedShareVolume($event)"
/>
<span class="w-10 text-right text-[11px] text-white/65">
{{ focusedShareMuted() ? 'Muted' : focusedShareVolume() + '%' }}
</span>
</div>
}
</div>
}
<div class="ml-auto flex items-center gap-2">
@if (isWidescreenMode() && hasMultipleShares()) {
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/35 px-3 py-2 text-xs font-medium text-white/80 transition hover:bg-black/55 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
}
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Minimize stream workspace"
(click)="minimizeWorkspace()"
>
<ng-icon
name="lucideMinimize"
class="h-4 w-4"
/>
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Return to chat"
(click)="closeWorkspace()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
</div>
@if (isWidescreenMode() && thumbnailShares().length > 0) {
<div
class="pointer-events-none absolute inset-x-3 bottom-3 z-10 transition-all duration-300 sm:inset-x-4 sm:bottom-4"
[class.opacity-0]="!showWorkspaceHeader()"
[class.translate-y-[12px]]="!showWorkspaceHeader()"
>
<div
class="pointer-events-auto rounded-2xl border border-white/10 bg-black/45 p-2.5 backdrop-blur-lg"
[class.pointer-events-none]="!showWorkspaceHeader()"
>
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/55">Other live streams</span>
<span class="text-[10px] text-white/40">{{ thumbnailShares().length }}</span>
</div>
<div class="flex gap-2 overflow-x-auto pb-1">
@for (share of thumbnailShares(); track trackShare($index, share)) {
<div class="h-[5.25rem] w-[9.5rem] shrink-0 sm:h-[5.5rem] sm:w-[10rem]">
<app-screen-share-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
</div>
</div>
}
<div
class="h-full min-h-0"
[ngClass]="isWidescreenMode() ? 'p-0' : 'p-3 pt-20 sm:p-4 sm:pt-24'"
>
@if (activeShares().length > 0) {
@if (isWidescreenMode() && widescreenShare()) {
<div class="h-full min-h-0">
<app-screen-share-stream-tile
[item]="widescreenShare()!"
[featured]="true"
[focused]="true"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
</div>
} @else {
<div
class="grid h-full min-h-0 auto-rows-[minmax(15rem,1fr)] grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 sm:gap-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
>
@for (share of activeShares(); track trackShare($index, share)) {
<div class="min-h-[15rem]">
<app-screen-share-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
}
} @else {
<div class="flex h-full items-center justify-center">
<div class="w-full max-w-3xl rounded-[2rem] border border-dashed border-white/10 bg-card/60 p-8 text-center shadow-2xl sm:p-10">
<div class="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-8 w-8"
/>
</div>
<h2 class="text-2xl font-semibold text-foreground">No live screen shares yet</h2>
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Click Screen Share below to start streaming, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
</p>
@if (connectedVoiceUsers().length > 0) {
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2">
<app-user-avatar
[name]="participant.displayName"
[avatarUrl]="participant.avatarUrl"
size="xs"
/>
<span class="text-sm text-foreground">{{ participant.displayName }}</span>
</div>
}
</div>
}
<div class="mt-8 flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground">
<span class="inline-flex items-center gap-2 rounded-full bg-secondary/70 px-4 py-2">
<ng-icon
name="lucideUsers"
class="h-4 w-4"
/>
{{ connectedVoiceUsers().length }} participants ready
</span>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
(click)="toggleScreenShare()"
>
<ng-icon
name="lucideMonitor"
class="h-4 w-4"
/>
Start screen sharing
</button>
</div>
</div>
</div>
}
</div>
</div>
</div>
</section>
}
@if (showMiniWindow()) {
<div
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
[style.left.px]="miniPosition().left"
[style.top.px]="miniPosition().top"
(dblclick)="restoreWorkspace()"
>
<div
class="flex cursor-move items-center gap-3 border-b border-white/10 bg-black/25 px-4 py-3"
(mousedown)="startMiniWindowDrag($event)"
>
<div class="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-4 w-4"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ connectedVoiceChannelName() }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }} · double-click to expand
</p>
</div>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Expand"
(click)="restoreWorkspace()"
>
<ng-icon
name="lucideMaximize"
class="h-4 w-4"
/>
</button>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Close"
(click)="closeWorkspace()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="relative aspect-video bg-black">
@if (miniPreviewShare()) {
<video
#miniPreview
autoplay
playsinline
class="h-full w-full bg-black object-cover"
></video>
} @else {
<div class="flex h-full items-center justify-center text-muted-foreground">
<div class="text-center">
<ng-icon
name="lucideMonitor"
class="mx-auto h-8 w-8 opacity-50"
/>
<p class="mt-2 text-sm">Waiting for a live stream</p>
</div>
</div>
}
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/50 to-transparent px-4 py-3 text-white">
<p class="truncate text-sm font-semibold">
{{ miniPreviewTitle() }}
</p>
<p class="truncate text-xs text-white/75">Connected to {{ serverName() }}</p>
</div>
</div>
</div>
}
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}
</div>

View File

@@ -0,0 +1,963 @@
/* eslint-disable @typescript-eslint/member-ordering, complexity */
import { CommonModule } from '@angular/common';
import {
Component,
DestroyRef,
ElementRef,
HostListener,
computed,
effect,
inject,
signal,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideHeadphones,
lucideMaximize,
lucideMic,
lucideMicOff,
lucideMinimize,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideUsers,
lucideVolume2,
lucideVolumeX,
lucideX
} from '@ng-icons/lucide';
import { User } from '../../../../shared-kernel';
import {
loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage,
VoiceSessionFacade,
VoiceWorkspacePosition,
VoiceWorkspaceService
} from '../../../../domains/voice-session';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { ScreenShareFacade } from '../../application/screen-share.facade';
import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
@Component({
selector: 'app-screen-share-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
ScreenShareQualityDialogComponent,
ScreenShareStreamTileComponent,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideHeadphones,
lucideMaximize,
lucideMic,
lucideMicOff,
lucideMinimize,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideUsers,
lucideVolume2,
lucideVolumeX,
lucideX
})
],
templateUrl: './screen-share-workspace.component.html',
host: {
class: 'pointer-events-none absolute inset-0 z-20 block'
}
})
export class ScreenShareWorkspaceComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly remoteStreamRevision = signal(0);
private readonly miniWindowWidth = 320;
private readonly miniWindowHeight = 228;
private miniWindowDragging = false;
private miniDragOffsetX = 0;
private miniDragOffsetY = 0;
private wasExpanded = false;
private wasAutoHideChrome = false;
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly observedRemoteStreams = new Map<string, {
stream: MediaStream;
cleanup: () => void;
}>();
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
readonly voiceSessionInfo = this.voiceSession.voiceSession;
readonly showExpanded = this.voiceWorkspace.isExpanded;
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
readonly showWorkspaceHeader = signal(true);
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
readonly isMuted = computed(() => this.webrtc.isMuted());
readonly isDeafened = computed(() => this.webrtc.isDeafened());
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false);
readonly connectedVoiceUsers = computed(() => {
const room = this.currentRoom();
const me = this.currentUser();
const roomId = me?.voiceState?.roomId;
const serverId = me?.voiceState?.serverId;
if (!room || !roomId || !serverId || serverId !== room.id) {
return [] as User[];
}
const voiceUsers = this.onlineUsers().filter(
(user) =>
!!user.voiceState?.isConnected
&& user.voiceState.roomId === roomId
&& user.voiceState.serverId === room.id
);
if (!me?.voiceState?.isConnected) {
return voiceUsers;
}
const currentKeys = new Set(voiceUsers.map((user) => user.oderId || user.id));
const meKey = me.oderId || me.id;
if (meKey && !currentKeys.has(meKey)) {
return [me, ...voiceUsers];
}
return voiceUsers;
});
readonly activeShares = computed<ScreenShareWorkspaceStreamItem[]>(() => {
this.remoteStreamRevision();
const room = this.currentRoom();
const me = this.currentUser();
const connectedRoomId = me?.voiceState?.roomId;
const connectedServerId = me?.voiceState?.serverId;
if (!room || !me || !connectedRoomId || connectedServerId !== room.id) {
return [];
}
const shares: ScreenShareWorkspaceStreamItem[] = [];
const localStream = this.screenShare.screenStream();
const localPeerKey = this.getUserPeerKey(me);
if (localStream && localPeerKey) {
shares.push({
id: localPeerKey,
peerKey: localPeerKey,
user: me,
stream: localStream,
isLocal: true
});
}
for (const user of this.onlineUsers()) {
const peerKey = this.getUserPeerKey(user);
if (!peerKey || peerKey === localPeerKey) {
continue;
}
if (
!user.voiceState?.isConnected
|| user.voiceState.roomId !== connectedRoomId
|| user.voiceState.serverId !== room.id
) {
continue;
}
if (user.screenShareState?.isSharing === false) {
continue;
}
const remoteShare = this.getRemoteShareStream(user);
if (!remoteShare) {
continue;
}
shares.push({
id: remoteShare.peerKey,
peerKey: remoteShare.peerKey,
user,
stream: remoteShare.stream,
isLocal: false
});
}
return shares.sort((shareA, shareB) => {
if (shareA.isLocal !== shareB.isLocal) {
return shareA.isLocal ? 1 : -1;
}
return shareA.user.displayName.localeCompare(shareB.user.displayName);
});
});
readonly widescreenShareId = computed(() => {
const requested = this.voiceWorkspace.focusedStreamId();
const activeShares = this.activeShares();
if (requested && activeShares.some((share) => share.peerKey === requested)) {
return requested;
}
if (activeShares.length === 1) {
return activeShares[0].peerKey;
}
return null;
});
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
readonly shouldAutoHideChrome = computed(
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly widescreenShare = computed(
() => this.activeShares().find((share) => share.peerKey === this.widescreenShareId()) ?? null
);
readonly focusedAudioShare = computed(() => {
const share = this.widescreenShare();
return share && !share.isLocal ? share : null;
});
readonly focusedShareTitle = computed(() => {
const share = this.widescreenShare();
if (!share) {
return 'Focused stream';
}
return share.isLocal ? 'Your stream' : share.user.displayName;
});
readonly thumbnailShares = computed(() => {
const widescreenShareId = this.widescreenShareId();
if (!widescreenShareId) {
return [] as ScreenShareWorkspaceStreamItem[];
}
return this.activeShares().filter((share) => share.peerKey !== widescreenShareId);
});
readonly miniPreviewShare = computed(
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
);
readonly miniPreviewTitle = computed(() => {
const previewShare = this.miniPreviewShare();
if (!previewShare) {
return 'Voice workspace';
}
return previewShare.isLocal ? 'Your stream' : previewShare.user.displayName;
});
readonly liveShareCount = computed(() => this.activeShares().length);
readonly connectedVoiceChannelName = computed(() => {
const me = this.currentUser();
const room = this.currentRoom();
const channelId = me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId;
const channel = room?.channels?.find(
(candidate) => candidate.id === channelId && candidate.type === 'voice'
);
if (channel) {
return channel.name;
}
const sessionRoomName = this.voiceSessionInfo()?.roomName?.replace(/^🔊\s*/, '');
return sessionRoomName || 'Voice Lounge';
});
readonly serverName = computed(
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || 'Voice server'
);
constructor() {
this.destroyRef.onDestroy(() => {
this.clearHeaderHideTimeout();
this.cleanupObservedRemoteStreams();
this.screenShare.syncRemoteScreenShareRequests([], false);
this.screenSharePlayback.teardownAll();
});
this.screenShare.onRemoteStream
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ peerId }) => {
this.observeRemoteStream(peerId);
this.bumpRemoteStreamRevision();
});
this.screenShare.onPeerDisconnected
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
effect(() => {
const ref = this.miniPreviewRef();
const previewShare = this.miniPreviewShare();
const showMiniWindow = this.showMiniWindow();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (!showMiniWindow || !previewShare) {
video.srcObject = null;
return;
}
if (video.srcObject !== previewShare.stream) {
video.srcObject = previewShare.stream;
}
video.muted = true;
video.volume = 0;
void video.play().catch(() => {});
});
effect(() => {
if (!this.showMiniWindow()) {
return;
}
requestAnimationFrame(() => this.ensureMiniWindowPosition());
});
effect(() => {
const shouldConnectRemoteShares = this.shouldConnectRemoteShares();
const currentUserPeerKey = this.getUserPeerKey(this.currentUser());
const peerKeys = Array.from(new Set(
this.connectedVoiceUsers()
.map((user) => this.getUserPeerKey(user))
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
));
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
if (!shouldConnectRemoteShares) {
this.screenSharePlayback.teardownAll();
}
});
effect(() => {
this.remoteStreamRevision();
const room = this.currentRoom();
const currentUser = this.currentUser();
const connectedRoomId = currentUser?.voiceState?.roomId;
const connectedServerId = currentUser?.voiceState?.serverId;
const peerKeys = new Set<string>();
if (room && connectedRoomId && connectedServerId === room.id) {
for (const user of this.onlineUsers()) {
if (
!user.voiceState?.isConnected
|| user.voiceState.roomId !== connectedRoomId
|| user.voiceState.serverId !== room.id
) {
continue;
}
for (const peerKey of [user.oderId, user.id]) {
if (!peerKey || peerKey === this.getUserPeerKey(currentUser)) {
continue;
}
peerKeys.add(peerKey);
this.observeRemoteStream(peerKey);
}
}
}
this.pruneObservedRemoteStreams(peerKeys);
});
effect(
() => {
const isExpanded = this.showExpanded();
const shouldAutoHideChrome = this.shouldAutoHideChrome();
if (!isExpanded) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = false;
this.wasAutoHideChrome = false;
return;
}
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true;
this.wasAutoHideChrome = false;
return;
}
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
this.wasExpanded = true;
this.wasAutoHideChrome = true;
if (shouldRevealChrome) {
this.revealWorkspaceChrome();
}
},
{ allowSignalWrites: true }
);
}
onWorkspacePointerMove(): void {
if (!this.shouldAutoHideChrome()) {
return;
}
this.revealWorkspaceChrome();
}
@HostListener('window:mousemove', ['$event'])
onWindowMouseMove(event: MouseEvent): void {
if (!this.miniWindowDragging) {
return;
}
event.preventDefault();
const bounds = this.getWorkspaceBounds();
const nextPosition = this.clampMiniWindowPosition({
left: event.clientX - bounds.left - this.miniDragOffsetX,
top: event.clientY - bounds.top - this.miniDragOffsetY
});
this.voiceWorkspace.setMiniWindowPosition(nextPosition);
}
@HostListener('window:mouseup')
onWindowMouseUp(): void {
this.miniWindowDragging = false;
}
@HostListener('window:resize')
onWindowResize(): void {
if (!this.showMiniWindow()) {
return;
}
this.ensureMiniWindowPosition();
}
trackUser(index: number, user: User): string {
return this.getUserPeerKey(user) || `${index}`;
}
trackShare(index: number, share: ScreenShareWorkspaceStreamItem): string {
return share.id || `${index}`;
}
focusShare(peerKey: string): void {
if (this.widescreenShareId() === peerKey) {
return;
}
this.voiceWorkspace.focusStream(peerKey);
}
showAllStreams(): void {
this.voiceWorkspace.clearFocusedStream();
}
minimizeWorkspace(): void {
this.voiceWorkspace.minimize();
this.ensureMiniWindowPosition();
}
restoreWorkspace(): void {
this.voiceWorkspace.restore();
}
closeWorkspace(): void {
this.voiceWorkspace.clearFocusedStream();
this.voiceWorkspace.close();
}
focusedShareVolume(): number {
const share = this.focusedAudioShare();
if (!share) {
return 100;
}
return this.screenSharePlayback.getUserVolume(share.peerKey);
}
focusedShareMuted(): boolean {
const share = this.focusedAudioShare();
if (!share) {
return false;
}
return this.screenSharePlayback.isUserMuted(share.peerKey);
}
toggleFocusedShareMuted(): void {
const share = this.focusedAudioShare();
if (!share) {
return;
}
this.screenSharePlayback.setUserMuted(
share.peerKey,
!this.screenSharePlayback.isUserMuted(share.peerKey)
);
}
updateFocusedShareVolume(event: Event): void {
const share = this.focusedAudioShare();
if (!share) {
return;
}
const input = event.target as HTMLInputElement;
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
this.screenSharePlayback.setUserVolume(share.peerKey, nextVolume);
if (nextVolume > 0 && this.screenSharePlayback.isUserMuted(share.peerKey)) {
this.screenSharePlayback.setUserMuted(share.peerKey, false);
}
}
startMiniWindowDrag(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input')) {
return;
}
event.preventDefault();
const bounds = this.getWorkspaceBounds();
const currentPosition = this.voiceWorkspace.miniWindowPosition();
this.miniWindowDragging = true;
this.miniDragOffsetX = event.clientX - bounds.left - currentPosition.left;
this.miniDragOffsetY = event.clientY - bounds.top - currentPosition.top;
}
toggleMute(): void {
const nextMuted = !this.isMuted();
this.webrtc.toggleMute(nextMuted);
this.syncVoiceState({
isConnected: this.isConnected(),
isMuted: nextMuted,
isDeafened: this.isDeafened()
});
this.broadcastVoiceState(nextMuted, this.isDeafened());
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();
let nextMuted = this.isMuted();
this.webrtc.toggleDeafen(nextDeafened);
this.voicePlayback.updateDeafened(nextDeafened);
if (nextDeafened && !nextMuted) {
nextMuted = true;
this.webrtc.toggleMute(true);
}
this.syncVoiceState({
isConnected: this.isConnected(),
isMuted: nextMuted,
isDeafened: nextDeafened
});
this.broadcastVoiceState(nextMuted, nextDeafened);
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
return;
}
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(): void {
this.webrtc.stopVoiceHeartbeat();
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
});
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
}
this.webrtc.disableVoice();
this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false);
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
}
})
);
}
this.voiceSession.endSession();
this.voiceWorkspace.reset();
}
getControlButtonClass(
isActive: boolean,
accent: 'default' | 'primary' | 'danger' = 'default'
): string {
const base = 'inline-flex min-w-[5.5rem] flex-col items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium transition-colors';
if (accent === 'danger') {
return `${base} bg-destructive text-destructive-foreground hover:bg-destructive/90`;
}
if (accent === 'primary' || isActive) {
return `${base} bg-primary/15 text-primary hover:bg-primary/25`;
}
return `${base} bg-secondary/80 text-foreground hover:bg-secondary`;
}
private bumpRemoteStreamRevision(): void {
this.remoteStreamRevision.update((value) => value + 1);
}
private syncVoiceState(voiceState: {
isConnected: boolean;
isMuted: boolean;
isDeafened: boolean;
}): void {
const user = this.currentUser();
const identifiers = this.getCurrentVoiceIdentifiers();
if (!user?.id) {
return;
}
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
...voiceState,
roomId: identifiers.roomId,
serverId: identifiers.serverId
}
})
);
}
private broadcastVoiceState(isMuted: boolean, isDeafened: boolean): void {
const identifiers = this.getCurrentVoiceIdentifiers();
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted,
isDeafened,
roomId: identifiers.roomId,
serverId: identifiers.serverId
}
});
}
private getCurrentVoiceIdentifiers(): {
roomId: string | undefined;
serverId: string | undefined;
} {
const me = this.currentUser();
return {
roomId: me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId,
serverId: me?.voiceState?.serverId ?? this.currentRoom()?.id ?? this.voiceSessionInfo()?.serverId
};
}
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> {
const options: ScreenShareStartOptions = {
includeSystemAudio: this.includeSystemAudio(),
quality
};
try {
await this.screenShare.startScreenShare(options);
this.voiceWorkspace.open(null);
} catch {
// Screen-share prompt was dismissed or failed.
}
}
private getUserPeerKey(user: User | null | undefined): string | null {
return user?.oderId || user?.id || null;
}
private getRemoteShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
const peerKeys = [user.oderId, user.id].filter(
(candidate): candidate is string => !!candidate
);
for (const peerKey of peerKeys) {
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
if (stream && this.hasActiveVideo(stream)) {
return { peerKey, stream };
}
}
return null;
}
private hasActiveVideo(stream: MediaStream): boolean {
return stream.getVideoTracks().some((track) => track.readyState === 'live');
}
private ensureMiniWindowPosition(): void {
const bounds = this.getWorkspaceBounds();
if (bounds.width === 0 || bounds.height === 0) {
return;
}
if (!this.voiceWorkspace.hasCustomMiniWindowPosition()) {
this.voiceWorkspace.setMiniWindowPosition(
this.clampMiniWindowPosition({
left: bounds.width - this.miniWindowWidth - 20,
top: bounds.height - this.miniWindowHeight - 20
}),
false
);
return;
}
this.voiceWorkspace.setMiniWindowPosition(
this.clampMiniWindowPosition(this.voiceWorkspace.miniWindowPosition()),
true
);
}
private clampMiniWindowPosition(position: VoiceWorkspacePosition): VoiceWorkspacePosition {
const bounds = this.getWorkspaceBounds();
const minLeft = 8;
const minTop = 8;
const maxLeft = Math.max(minLeft, bounds.width - this.miniWindowWidth - 8);
const maxTop = Math.max(minTop, bounds.height - this.miniWindowHeight - 8);
return {
left: this.clamp(position.left, minLeft, maxLeft),
top: this.clamp(position.top, minTop, maxTop)
};
}
private getWorkspaceBounds(): DOMRect {
return this.elementRef.nativeElement.getBoundingClientRect();
}
private observeRemoteStream(peerKey: string): void {
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
const existing = this.observedRemoteStreams.get(peerKey);
if (!stream) {
if (existing) {
existing.cleanup();
this.observedRemoteStreams.delete(peerKey);
}
return;
}
if (existing?.stream === stream) {
return;
}
existing?.cleanup();
const onChanged = () => this.bumpRemoteStreamRevision();
const trackCleanups: (() => void)[] = [];
const bindTrack = (track: MediaStreamTrack) => {
if (track.kind !== 'video') {
return;
}
const onTrackChanged = () => onChanged();
track.addEventListener('ended', onTrackChanged);
track.addEventListener('mute', onTrackChanged);
track.addEventListener('unmute', onTrackChanged);
trackCleanups.push(() => {
track.removeEventListener('ended', onTrackChanged);
track.removeEventListener('mute', onTrackChanged);
track.removeEventListener('unmute', onTrackChanged);
});
};
stream.getVideoTracks().forEach((track) => bindTrack(track));
const onAddTrack = (event: MediaStreamTrackEvent) => {
bindTrack(event.track);
onChanged();
};
const onRemoveTrack = () => onChanged();
stream.addEventListener('addtrack', onAddTrack);
stream.addEventListener('removetrack', onRemoveTrack);
this.observedRemoteStreams.set(peerKey, {
stream,
cleanup: () => {
stream.removeEventListener('addtrack', onAddTrack);
stream.removeEventListener('removetrack', onRemoveTrack);
trackCleanups.forEach((cleanup) => cleanup());
}
});
onChanged();
}
private pruneObservedRemoteStreams(activePeerKeys: Set<string>): void {
for (const [peerKey, observed] of this.observedRemoteStreams.entries()) {
if (activePeerKeys.has(peerKey)) {
continue;
}
observed.cleanup();
this.observedRemoteStreams.delete(peerKey);
}
}
private cleanupObservedRemoteStreams(): void {
for (const observed of this.observedRemoteStreams.values()) {
observed.cleanup();
}
this.observedRemoteStreams.clear();
}
private scheduleHeaderHide(): void {
this.clearHeaderHideTimeout();
this.headerHideTimeoutId = setTimeout(() => {
this.showWorkspaceHeader.set(false);
this.headerHideTimeoutId = null;
}, 2200);
}
private revealWorkspaceChrome(): void {
this.showWorkspaceHeader.set(true);
this.scheduleHeaderHide();
}
private clearHeaderHideTimeout(): void {
if (this.headerHideTimeoutId === null) {
return;
}
clearTimeout(this.headerHideTimeoutId);
this.headerHideTimeoutId = null;
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}

View File

@@ -0,0 +1,9 @@
import { User } from '../../../../shared-kernel';
export interface ScreenShareWorkspaceStreamItem {
id: string;
peerKey: string;
user: User;
stream: MediaStream;
isLocal: boolean;
}

View File

@@ -0,0 +1,8 @@
export * from './application/screen-share.facade';
export * from './application/screen-share-source-picker.service';
export * from './domain/screen-share.config';
// Feature components
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';
export { ScreenShareWorkspaceComponent } from './feature/screen-share-workspace/screen-share-workspace.component';
export { ScreenShareStreamTileComponent } from './feature/screen-share-workspace/screen-share-stream-tile.component';