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,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);
}
}
}
}