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