import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, computed, input, output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideDownload, lucideMaximize, lucideMinimize, lucidePause, lucidePlay, lucideVolume2, lucideVolumeX } from '@ng-icons/lucide'; import { MEDIA_SEEK_SLIDER_STEPS, mediaSeekSliderValue, mediaSeekTimeFromSliderValue } from './chat-video-player-seek.rules'; @Component({ selector: 'app-chat-video-player', standalone: true, imports: [CommonModule, NgIcon], viewProviders: [ provideIcons({ lucideDownload, lucideMaximize, lucideMinimize, lucidePause, lucidePlay, lucideVolume2, lucideVolumeX }) ], templateUrl: './chat-video-player.component.html', styleUrl: './chat-video-player.component.scss' }) /* eslint-disable @typescript-eslint/member-ordering */ export class ChatVideoPlayerComponent implements OnInit, OnDestroy { src = input.required(); filename = input.required(); sizeLabel = input(''); downloadRequested = output(); private readonly SINGLE_CLICK_DELAY_MS = 300; private readonly CONTROLS_IDLE_MS = 2200; @ViewChild('playerRoot') playerRoot?: ElementRef; @ViewChild('videoEl') videoRef?: ElementRef; isPlaying = signal(false); isMuted = signal(false); isFullscreen = signal(false); controlsVisible = signal(true); currentTimeSeconds = signal(0); durationSeconds = signal(0); volumePercent = signal(100); private lastNonZeroVolume = signal(100); private singleClickTimer: ReturnType | null = null; private controlsHideTimer: ReturnType | null = null; readonly seekSliderSteps = MEDIA_SEEK_SLIDER_STEPS; progressPercent = computed(() => { const duration = this.durationSeconds(); if (duration <= 0) return 0; return (this.currentTimeSeconds() / duration) * 100; }); seekSliderValue = computed(() => mediaSeekSliderValue(this.currentTimeSeconds(), this.durationSeconds())); volumeProgressPercent = computed(() => Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()))); @HostListener('document:fullscreenchange') onFullscreenChange(): void { const player = this.playerRoot?.nativeElement; const isFullscreen = !!player && document.fullscreenElement === player; this.isFullscreen.set(isFullscreen); this.revealControlsTemporarily(); } ngOnInit(): void { this.revealControlsTemporarily(); } ngOnDestroy(): void { this.clearControlsHideTimer(); this.clearSingleClickTimer(); } onPlayerMouseMove(): void { this.revealControlsTemporarily(); } onVideoClick(): void { this.clearSingleClickTimer(); this.revealControlsTemporarily(); this.singleClickTimer = setTimeout(() => { this.singleClickTimer = null; this.togglePlayback(); }, this.SINGLE_CLICK_DELAY_MS); } onVideoDoubleClick(event: MouseEvent): void { event.preventDefault(); event.stopPropagation(); this.clearSingleClickTimer(); void this.toggleFullscreen(); } onOverlayPlayClick(event: MouseEvent): void { event.stopPropagation(); this.togglePlayback(); } togglePlayback(): void { const video = this.videoRef?.nativeElement; if (!video) return; if (video.paused || video.ended) { void video.play().catch(() => { this.isPlaying.set(false); }); return; } video.pause(); this.revealControlsTemporarily(); } onLoadedMetadata(): void { const video = this.videoRef?.nativeElement; if (!video) return; this.durationSeconds.set(Number.isFinite(video.duration) ? video.duration : 0); this.currentTimeSeconds.set(video.currentTime || 0); } onTimeUpdate(): void { const video = this.videoRef?.nativeElement; if (!video) return; this.currentTimeSeconds.set(video.currentTime || 0); } onPlay(): void { this.isPlaying.set(true); this.revealControlsTemporarily(); } onPause(): void { this.isPlaying.set(false); this.revealControlsTemporarily(); } onSeek(event: Event): void { const video = this.videoRef?.nativeElement; if (!video) return; const sliderValue = Number((event.target as HTMLInputElement).value); if (!Number.isFinite(sliderValue)) return; const nextTime = mediaSeekTimeFromSliderValue(sliderValue, this.durationSeconds()); video.currentTime = nextTime; this.currentTimeSeconds.set(nextTime); this.revealControlsTemporarily(); } onVolumeInput(event: Event): void { const video = this.videoRef?.nativeElement; if (!video) return; const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value))); video.volume = nextVolume / 100; video.muted = nextVolume === 0; if (nextVolume > 0) this.lastNonZeroVolume.set(nextVolume); this.volumePercent.set(nextVolume); this.isMuted.set(video.muted); this.revealControlsTemporarily(); } onVolumeChange(): void { const video = this.videoRef?.nativeElement; if (!video) return; this.isMuted.set(video.muted || video.volume === 0); if (!video.muted && video.volume > 0) { const volume = Math.round(video.volume * 100); this.volumePercent.set(volume); this.lastNonZeroVolume.set(volume); } this.revealControlsTemporarily(); } toggleMute(): void { const video = this.videoRef?.nativeElement; if (!video) return; if (video.muted || video.volume === 0) { const restoredVolume = Math.max(this.lastNonZeroVolume(), 1); video.muted = false; video.volume = restoredVolume / 100; this.volumePercent.set(restoredVolume); this.isMuted.set(false); this.revealControlsTemporarily(); return; } video.muted = true; this.isMuted.set(true); this.revealControlsTemporarily(); } async toggleFullscreen(): Promise { const player = this.playerRoot?.nativeElement; if (!player) return; if (document.fullscreenElement === player) { await document.exitFullscreen().catch(() => {}); return; } await player.requestFullscreen?.().catch(() => {}); } requestDownload(): void { this.downloadRequested.emit(undefined); this.revealControlsTemporarily(); } private revealControlsTemporarily(): void { this.controlsVisible.set(true); this.clearControlsHideTimer(); this.controlsHideTimer = setTimeout(() => { this.controlsVisible.set(false); }, this.CONTROLS_IDLE_MS); } private clearControlsHideTimer(): void { if (this.controlsHideTimer) { clearTimeout(this.controlsHideTimer); this.controlsHideTimer = null; } } private clearSingleClickTimer(): void { if (this.singleClickTimer) { clearTimeout(this.singleClickTimer); this.singleClickTimer = null; } } formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const totalSeconds = Math.floor(seconds); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const remainingSeconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; } return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } }