138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input, OnDestroy, AfterViewInit } from '@angular/core';
|
|
import { HistoryService } from '../../services/history.service';
|
|
|
|
@Component({
|
|
selector: 'app-video-player',
|
|
templateUrl: './video-player.component.html',
|
|
standalone: true,
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class VideoPlayerComponent implements AfterViewInit, OnDestroy {
|
|
videoSource = input.required<string>();
|
|
// Optional resume position (in seconds)
|
|
startPositionSeconds = input<number | null>(null);
|
|
// Watch history id to update progress
|
|
watchHistoryId = input<string | null>(null);
|
|
|
|
@ViewChild('player', { static: false }) playerRef?: ElementRef<HTMLVideoElement>;
|
|
|
|
get el(): HTMLVideoElement | null {
|
|
return this.playerRef?.nativeElement ?? null;
|
|
}
|
|
|
|
private progressTimer: any = null;
|
|
private lastSent = 0;
|
|
|
|
constructor(private history: HistoryService) {}
|
|
|
|
ngAfterViewInit(): void {
|
|
const v = this.el; if (!v) return;
|
|
// Seek to saved position when metadata is ready
|
|
v.addEventListener('loadedmetadata', () => {
|
|
const pos = this.startPositionSeconds();
|
|
if (typeof pos === 'number' && pos > 0 && isFinite(pos)) {
|
|
try { v.currentTime = Math.min(Math.max(pos, 0), v.duration || pos); } catch {}
|
|
}
|
|
});
|
|
|
|
// Start periodic progress updates when playing
|
|
v.addEventListener('play', () => {
|
|
// Ensure we seek on first play if metadata event already fired
|
|
const pos = this.startPositionSeconds();
|
|
if (typeof pos === 'number' && pos > 0 && isFinite(pos)) {
|
|
try {
|
|
if (!isNaN(v.currentTime) && v.currentTime < Math.max(0, pos - 1)) v.currentTime = pos;
|
|
} catch {}
|
|
}
|
|
this.startProgressLoop();
|
|
});
|
|
v.addEventListener('pause', () => this.sendProgressOnce());
|
|
v.addEventListener('ended', () => { this.sendProgressOnce(true); this.stopProgressLoop(); });
|
|
|
|
// As a safeguard, send progress on visibility change/unload
|
|
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.addEventListener('beforeunload', this.onBeforeUnload);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.stopProgressLoop();
|
|
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
|
}
|
|
|
|
private onVisibilityChange = () => {
|
|
if (document.hidden) this.sendProgressOnce();
|
|
};
|
|
|
|
private onBeforeUnload = () => {
|
|
this.sendProgressOnce();
|
|
};
|
|
|
|
private startProgressLoop(): void {
|
|
this.stopProgressLoop();
|
|
this.progressTimer = setInterval(() => this.sendProgressDebounced(), 4000);
|
|
}
|
|
|
|
private stopProgressLoop(): void {
|
|
if (this.progressTimer) {
|
|
clearInterval(this.progressTimer);
|
|
this.progressTimer = null;
|
|
}
|
|
}
|
|
|
|
private currentPos(): number {
|
|
const v = this.el; if (!v) return 0;
|
|
try { return Math.max(0, Math.floor(v.currentTime || 0)); } catch { return 0; }
|
|
}
|
|
|
|
private sendProgressDebounced(): void {
|
|
const now = Date.now();
|
|
if (now - this.lastSent < 3500) return;
|
|
this.lastSent = now;
|
|
this.sendProgress(false);
|
|
}
|
|
|
|
private sendProgressOnce(final: boolean = false): void {
|
|
this.sendProgress(final);
|
|
}
|
|
|
|
private sendProgress(final: boolean): void {
|
|
const id = this.watchHistoryId();
|
|
if (!id) return;
|
|
const pos = this.currentPos();
|
|
this.history.updateWatchProgress(id, pos, pos).subscribe({ next: () => {}, error: () => {} });
|
|
if (final) this.lastSent = Date.now();
|
|
}
|
|
|
|
togglePlayPause(): void {
|
|
const v = this.el; if (!v) return;
|
|
if (v.paused) v.play(); else v.pause();
|
|
}
|
|
|
|
seekBy(seconds: number): void {
|
|
const v = this.el; if (!v) return;
|
|
try { v.currentTime = Math.max(0, v.currentTime + seconds); } catch {}
|
|
}
|
|
|
|
toggleMute(): void {
|
|
const v = this.el; if (!v) return;
|
|
v.muted = !v.muted;
|
|
}
|
|
|
|
setPlaybackRate(delta: number): void {
|
|
const v = this.el; if (!v) return;
|
|
const next = Math.min(2.0, Math.max(0.25, (v.playbackRate || 1) + delta));
|
|
v.playbackRate = next;
|
|
}
|
|
|
|
async toggleFullscreen(): Promise<void> {
|
|
const v = this.el; if (!v) return;
|
|
const anyDoc: any = document;
|
|
if (!anyDoc.fullscreenElement) {
|
|
await v.requestFullscreen?.();
|
|
} else {
|
|
await anyDoc.exitFullscreen?.();
|
|
}
|
|
}
|
|
}
|