310 lines
7.5 KiB
TypeScript
310 lines
7.5 KiB
TypeScript
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<string>();
|
|
filename = input.required<string>();
|
|
sizeLabel = input<string>('');
|
|
downloadRequested = output<undefined>();
|
|
|
|
private readonly SINGLE_CLICK_DELAY_MS = 300;
|
|
private readonly CONTROLS_IDLE_MS = 2200;
|
|
|
|
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
|
|
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
|
|
|
|
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<typeof setTimeout> | null = null;
|
|
private controlsHideTimer: ReturnType<typeof setTimeout> | 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<void> {
|
|
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')}`;
|
|
}
|
|
}
|