feat: Add webcam basic support
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
<app-screen-share-workspace />
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
||||
import { ScreenShareWorkspaceComponent } from '../../../domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component';
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
|
||||
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
@@ -39,7 +39,7 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareWorkspaceComponent,
|
||||
VoiceWorkspaceComponent,
|
||||
RoomsSidePanelComponent
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -201,11 +201,15 @@
|
||||
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
|
||||
></span>
|
||||
}
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
@if (isUserStreaming(u.oderId || u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.oderId || u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="getUserLiveIconName(u.oderId || u.id)"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@@ -261,13 +265,13 @@
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
|
||||
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
|
||||
<button
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse hover:bg-red-600 transition-colors"
|
||||
(click)="viewStream(currentUser()!.oderId || currentUser()!.id); $event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
[name]="getUserLiveIconName(currentUser()!.oderId || currentUser()!.id)"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
@@ -318,13 +322,13 @@
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
|
||||
@if (isUserStreaming(user.oderId || user.id)) {
|
||||
<button
|
||||
(click)="viewStream(user.oderId || user.id); $event.stopPropagation()"
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
[name]="getUserLiveIconName(user.oderId || user.id)"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideMonitor,
|
||||
lucideVideo,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
@@ -40,10 +41,7 @@ import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/vo
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
import {
|
||||
isChannelNameTaken,
|
||||
normalizeChannelName
|
||||
} from '../../../store/rooms/room-channels.rules';
|
||||
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
|
||||
import {
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
@@ -81,6 +79,7 @@ type TabView = 'channels' | 'users';
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideMonitor,
|
||||
lucideVideo,
|
||||
lucideHash,
|
||||
lucideUsers,
|
||||
lucidePlus,
|
||||
@@ -274,6 +273,7 @@ export class RoomsSidePanelComponent {
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,7 +334,6 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
confirmCreateChannel() {
|
||||
const name = normalizeChannelName(this.newChannelName);
|
||||
|
||||
const validationError = this.getChannelNameError(name);
|
||||
|
||||
if (validationError) {
|
||||
@@ -597,6 +596,13 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: current.id,
|
||||
cameraState: { isEnabled: false }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
@@ -620,11 +626,15 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
||||
this.voiceWorkspace.focusStream(`screen:${userId}`, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
viewStream(userId: string) {
|
||||
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
|
||||
const focusTarget = this.isUserSharing(userId)
|
||||
? `screen:${userId}`
|
||||
: `camera:${userId}`;
|
||||
|
||||
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
|
||||
}
|
||||
|
||||
canMoveVoiceUsers(): boolean {
|
||||
@@ -740,31 +750,65 @@ export class RoomsSidePanelComponent {
|
||||
return this.voicePlayback.isUserMuted(peerId);
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
isUserOnCamera(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (me?.id === userId) {
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.voiceConnection.isCameraEnabled();
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.cameraState?.isEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getPeerKeysForUser(user, userId)
|
||||
.some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const user = this.findKnownUser(userId);
|
||||
|
||||
if (!this.isUserInCurrentVoiceRoom(userId, user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return this.screenShare.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||
if (user?.screenShareState?.isSharing === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const peerKeys = [
|
||||
user?.oderId,
|
||||
user?.id,
|
||||
userId
|
||||
].filter(
|
||||
(candidate): candidate is string => !!candidate
|
||||
);
|
||||
const stream = peerKeys
|
||||
const stream = this.getPeerKeysForUser(user, userId)
|
||||
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
|
||||
.find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null;
|
||||
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
|
||||
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
return this.hasActiveVideoStream(stream);
|
||||
}
|
||||
|
||||
isUserStreaming(userId: string): boolean {
|
||||
return this.isUserSharing(userId) || this.isUserOnCamera(userId);
|
||||
}
|
||||
|
||||
getUserLiveIconName(userId: string): string {
|
||||
return this.isUserSharing(userId) ? 'lucideMonitor' : 'lucideVideo';
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
@@ -829,4 +873,45 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
private findKnownUser(userId: string): User | null {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) ?? null;
|
||||
}
|
||||
|
||||
private isUserInCurrentVoiceRoom(userId: string, user: User | null): boolean {
|
||||
const currentVoiceState = this.currentUser()?.voiceState;
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!currentVoiceState?.isConnected || !currentVoiceState.roomId || !currentVoiceState.serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current && (current.id === userId || current.oderId === userId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!user?.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === currentVoiceState.roomId
|
||||
&& user.voiceState.serverId === currentVoiceState.serverId;
|
||||
}
|
||||
|
||||
private getPeerKeysForUser(user: User | null, userId: string): string[] {
|
||||
return [
|
||||
user?.oderId,
|
||||
user?.id,
|
||||
userId
|
||||
].filter(
|
||||
(candidate): candidate is string => !!candidate
|
||||
);
|
||||
}
|
||||
|
||||
private hasActiveVideoStream(stream: MediaStream | null): boolean {
|
||||
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||
}
|
||||
}
|
||||
|
||||
29
toju-app/src/app/features/room/voice-workspace/README.md
Normal file
29
toju-app/src/app/features/room/voice-workspace/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Voice Workspace Feature
|
||||
|
||||
Room-level composition shell for live voice-channel streams. This feature owns the mixed workspace UI that can show:
|
||||
|
||||
- screen shares
|
||||
- webcam streams
|
||||
- voice-session workspace state such as expanded, minimized, focused, and mini-window position
|
||||
|
||||
It intentionally lives under `features/room/` instead of any single domain because it composes multiple domains together:
|
||||
|
||||
- `VoiceWorkspaceService` from `domains/voice-session` for panel state
|
||||
- `VoiceConnectionFacade` from `domains/voice-connection` for voice and camera streams
|
||||
- `ScreenShareFacade` from `domains/screen-share` for screen-share capture and remote share requests
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
voice-workspace/
|
||||
├── voice-workspace.component.ts Live stream workspace shell
|
||||
├── voice-workspace.component.html Grid, widescreen, and mini-window layout
|
||||
├── voice-workspace-stream-tile.component.ts Per-stream tile UI and fullscreen controls
|
||||
├── voice-workspace-stream-tile.component.html
|
||||
├── voice-workspace-playback.service.ts Per-peer playback mute/volume state for stream audio
|
||||
└── voice-workspace.models.ts Stream item contracts (`screen` or `camera`)
|
||||
```
|
||||
|
||||
## Boundary
|
||||
|
||||
This feature is the right home for mixed live-stream presentation. Screen-share-specific behavior stays in `domains/screen-share`, and voice/camera transport stays in `domains/voice-connection` plus `infrastructure/realtime`.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
interface VoiceWorkspacePlaybackSettings {
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: VoiceWorkspacePlaybackSettings = {
|
||||
muted: false,
|
||||
volume: 100
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceWorkspacePlaybackService {
|
||||
private readonly _settings = signal<ReadonlyMap<string, VoiceWorkspacePlaybackSettings>>(new Map());
|
||||
|
||||
settings(): ReadonlyMap<string, VoiceWorkspacePlaybackSettings> {
|
||||
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 {
|
||||
// Stream audio is played directly by the video element.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<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() + ' ' + streamBadgeLabel() : 'Open ' + displayName() + ' ' + streamBadgeLabel() + ' 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"
|
||||
[class.object-contain]="item().kind === 'screen'"
|
||||
[class.object-cover]="item().kind === 'camera'"
|
||||
></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">
|
||||
{{ streamBadgeLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-xs text-white/60">
|
||||
{{ fullscreenDescription() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!item().isLocal && item().hasAudio) {
|
||||
<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">{{ streamBadgeLabel() }}</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]="streamIconName()"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
{{ streamBadgeLabel() }}
|
||||
</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 && item().hasAudio) {
|
||||
<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">
|
||||
{{ localPreviewDescription() }}
|
||||
</div>
|
||||
}
|
||||
} @else if (item().hasAudio) {
|
||||
@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>
|
||||
@@ -0,0 +1,291 @@
|
||||
/* 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,
|
||||
lucideVideo,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UserAvatarComponent } from '../../../shared';
|
||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-workspace-stream-tile',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideVideo,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-workspace-stream-tile.component.html',
|
||||
host: {
|
||||
class: 'block h-full'
|
||||
}
|
||||
})
|
||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||
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.workspacePlayback.settings();
|
||||
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal || !item.hasAudio) {
|
||||
this.volume.set(0);
|
||||
this.muted.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.volume.set(this.workspacePlayback.getUserVolume(item.peerKey));
|
||||
this.muted.set(this.workspacePlayback.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 || !item.hasAudio) {
|
||||
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().id);
|
||||
}
|
||||
|
||||
toggleMuted(): void {
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal || !item.hasAudio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMuted = !this.muted();
|
||||
|
||||
this.muted.set(nextMuted);
|
||||
this.workspacePlayback.setUserMuted(item.peerKey, nextMuted);
|
||||
}
|
||||
|
||||
updateVolume(event: Event): void {
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal || !item.hasAudio) {
|
||||
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.workspacePlayback.setUserVolume(item.peerKey, nextVolume);
|
||||
|
||||
if (nextVolume > 0 && this.muted()) {
|
||||
this.muted.set(false);
|
||||
this.workspacePlayback.setUserMuted(item.peerKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
displayName(): string {
|
||||
return this.item().isLocal ? 'You' : this.item().user.displayName;
|
||||
}
|
||||
|
||||
streamIconName(): string {
|
||||
return this.item().kind === 'camera' ? 'lucideVideo' : 'lucideMonitor';
|
||||
}
|
||||
|
||||
streamBadgeLabel(): string {
|
||||
return this.item().kind === 'camera' ? 'Camera live' : 'Screen share live';
|
||||
}
|
||||
|
||||
fullscreenDescription(): string {
|
||||
if (this.item().isLocal) {
|
||||
return this.item().kind === 'camera'
|
||||
? 'Local camera preview in fullscreen'
|
||||
: 'Local preview in fullscreen';
|
||||
}
|
||||
|
||||
return this.item().kind === 'camera'
|
||||
? 'Fullscreen camera view'
|
||||
: 'Fullscreen stream view';
|
||||
}
|
||||
|
||||
localPreviewDescription(): string {
|
||||
return this.item().kind === 'camera'
|
||||
? 'Your camera preview never captures audio.'
|
||||
: 'Your preview stays muted locally to avoid audio feedback.';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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-voice-workspace-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-voice-workspace-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-voice-workspace-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 streams yet</h2>
|
||||
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Turn on your camera, click Screen Share below, 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import { User } from '../../../shared-kernel';
|
||||
|
||||
export type VoiceWorkspaceStreamKind = 'camera' | 'screen';
|
||||
|
||||
export interface VoiceWorkspaceStreamItem {
|
||||
id: string;
|
||||
peerKey: string;
|
||||
user: User;
|
||||
stream: MediaStream;
|
||||
isLocal: boolean;
|
||||
kind: VoiceWorkspaceStreamKind;
|
||||
hasAudio: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user