Move toju-app into own its folder
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
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';
|
||||
|
||||
@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 OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
private readonly SINGLE_CLICK_DELAY_MS = 300;
|
||||
private readonly FULLSCREEN_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;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
if (duration <= 0)
|
||||
return 0;
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => {
|
||||
const progress = Math.max(0, Math.min(100, this.progressPercent()));
|
||||
|
||||
return this.buildSliderBackground(progress);
|
||||
});
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
const player = this.playerRoot?.nativeElement;
|
||||
const isFullscreen = !!player && document.fullscreenElement === player;
|
||||
|
||||
this.isFullscreen.set(isFullscreen);
|
||||
|
||||
if (isFullscreen) {
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearControlsHideTimer();
|
||||
this.clearSingleClickTimer();
|
||||
}
|
||||
|
||||
onPlayerMouseMove(): void {
|
||||
if (!this.isFullscreen())
|
||||
return;
|
||||
|
||||
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 nextTime = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
return;
|
||||
|
||||
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 buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
private revealControlsTemporarily(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
this.controlsVisible.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
this.controlsHideTimer = setTimeout(() => {
|
||||
this.controlsVisible.set(false);
|
||||
}, this.FULLSCREEN_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')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user