chore: update Angular cache and TypeScript build info
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input, OnDestroy, AfterViewInit } from '@angular/core';
|
||||
import { HistoryService } from '../../services/history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
@@ -6,8 +7,12 @@ import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input } from
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class VideoPlayerComponent {
|
||||
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>;
|
||||
|
||||
@@ -15,6 +20,90 @@ export class VideoPlayerComponent {
|
||||
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();
|
||||
|
||||
@@ -28,12 +28,16 @@
|
||||
<div class="lg:w-2/3">
|
||||
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
|
||||
@if (embedUrl(); as src) {
|
||||
<iframe class="w-full h-full" [src]="src" frameborder="0"
|
||||
<iframe #embedFrame class="w-full h-full" [src]="src" frameborder="0"
|
||||
referrerpolicy="origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen></iframe>
|
||||
} @else {
|
||||
<app-video-player [videoSource]="videoSource()"></app-video-player>
|
||||
<app-video-player
|
||||
[videoSource]="videoSource()"
|
||||
[startPositionSeconds]="startPositionSeconds()"
|
||||
[watchHistoryId]="watchHistoryId()"
|
||||
></app-video-player>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
@@ -13,6 +13,7 @@ import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||
|
||||
@Component({
|
||||
@@ -22,7 +23,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink]
|
||||
})
|
||||
export class WatchComponent implements OnDestroy {
|
||||
export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private apiService = inject(YoutubeApiService);
|
||||
@@ -34,7 +35,10 @@ export class WatchComponent implements OnDestroy {
|
||||
private likes = inject(LikesService);
|
||||
private auth = inject(AuthService);
|
||||
private http = inject(HttpClient);
|
||||
private routeSubscription: Subscription;
|
||||
private iframeProgress = inject(IframeProgressService);
|
||||
private routeSubscription!: Subscription;
|
||||
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
|
||||
private attachTryCount = 0;
|
||||
|
||||
// Choose correct API base: use dev proxy when UI runs on a different port than the backend
|
||||
private apiBase(): string {
|
||||
@@ -47,6 +51,45 @@ export class WatchComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private attachIframeTracking(resetTries: boolean = false): void {
|
||||
try {
|
||||
if (resetTries) {
|
||||
console.log('[Watch] Resetting iframe tracking');
|
||||
this.attachTryCount = 0;
|
||||
}
|
||||
|
||||
const watchId = this.watchHistoryId();
|
||||
const p = this.provider();
|
||||
const el = this.embedFrame?.nativeElement || null;
|
||||
const start = this.startPositionSeconds();
|
||||
|
||||
console.log('[Watch] Attaching iframe tracking', {
|
||||
watchId,
|
||||
provider: p,
|
||||
hasIframe: !!el,
|
||||
tryCount: this.attachTryCount,
|
||||
start
|
||||
});
|
||||
|
||||
// Wait for start position to be known (up to ~3s) to ensure proper resume
|
||||
if (watchId && p && el && (start !== null || this.attachTryCount >= 20)) {
|
||||
console.log('[Watch] All conditions met, attaching iframe progress tracking');
|
||||
this.iframeProgress.attach(p, el, watchId, start ?? 0);
|
||||
this.attachTryCount = 0;
|
||||
} else if (this.attachTryCount < 20) {
|
||||
this.attachTryCount++;
|
||||
console.log(`[Watch] Conditions not met, retrying (${this.attachTryCount}/20)`);
|
||||
setTimeout(() => this.attachIframeTracking(), 150);
|
||||
} else {
|
||||
console.warn('[Watch] Failed to attach iframe tracking after max retries', {
|
||||
watchId,
|
||||
provider: p,
|
||||
hasIframe: !!el
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
video = signal<VideoDetail | null>(null);
|
||||
loading = signal(true);
|
||||
showFullDescription = signal(false);
|
||||
@@ -68,7 +111,11 @@ export class WatchComponent implements OnDestroy {
|
||||
geminiReady = this.geminiService.isReady();
|
||||
geminiReason = this.geminiService.readinessReason();
|
||||
// Auth status for gating download UI
|
||||
isLoggedIn = computed(() => !!this.auth.currentUser());
|
||||
isLoggedIn(): boolean {
|
||||
const isLoggedIn = this.auth.currentUser() !== null;
|
||||
console.log('[Watch] isLoggedIn check:', isLoggedIn);
|
||||
return isLoggedIn;
|
||||
}
|
||||
// Quality selection (progressive streams only to ensure audio present)
|
||||
selectedQuality = signal<string | null>(null);
|
||||
progressiveStreams = computed(() => (this.video()?.videoStreams || []).filter(s => !s.videoOnly));
|
||||
@@ -150,6 +197,10 @@ export class WatchComponent implements OnDestroy {
|
||||
liked = signal<boolean>(false);
|
||||
likeBusy = signal<boolean>(false);
|
||||
|
||||
// --- Watch history tracking for native player ---
|
||||
watchHistoryId = signal<string | null>(null);
|
||||
startPositionSeconds = signal<number | null>(null);
|
||||
|
||||
// Route params for building embeds
|
||||
private videoId = signal<string>('');
|
||||
private odyseeSlug = signal<string | null>(null);
|
||||
@@ -161,17 +212,23 @@ export class WatchComponent implements OnDestroy {
|
||||
const p = this.provider();
|
||||
const ch = this.twitchChannel();
|
||||
const slug = this.odyseeSlug();
|
||||
const start = Math.max(0, this.startPositionSeconds() || 0);
|
||||
if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
|
||||
try {
|
||||
const host = (location && location.hostname) ? location.hostname : 'localhost';
|
||||
if (p === 'youtube') {
|
||||
const origin = window.location.origin || 'https://' + (window.location.hostname || 'localhost');
|
||||
const ref = typeof window !== 'undefined' ? window.location.href : '';
|
||||
const u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(ref)}`;
|
||||
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(ref)}`;
|
||||
if (start > 0) u += `&start=${start}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'dailymotion') {
|
||||
const u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(id)}?autoplay=1&mute=1`;
|
||||
// Use standard Dailymotion embed player and enable JS API
|
||||
const origin = (typeof window !== 'undefined' && window.location && window.location.origin) ? window.location.origin : '';
|
||||
let u = `https://www.dailymotion.com/embed/video/${encodeURIComponent(id)}?api=1&autoplay=1&mute=1`;
|
||||
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
|
||||
if (start > 0) u += `&start=${start}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'twitch') {
|
||||
@@ -187,7 +244,9 @@ export class WatchComponent implements OnDestroy {
|
||||
}
|
||||
if (p === 'peertube') {
|
||||
const inst = this.instances.activePeerTubeInstance();
|
||||
const u = `https://${inst}/videos/embed/${encodeURIComponent(id)}?autoplay=1`;
|
||||
// Enable postMessage API
|
||||
let u = `https://${inst}/videos/embed/${encodeURIComponent(id)}?autoplay=1&api=1`;
|
||||
if (start > 0) u += `&start=${start}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'odysee') {
|
||||
@@ -244,6 +303,10 @@ export class WatchComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Attachment now happens when recordWatchStart returns with start position
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSubscription.unsubscribe();
|
||||
}
|
||||
@@ -282,6 +345,7 @@ export class WatchComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
loadVideo(id: string) {
|
||||
console.log('[Watch] Loading video', { id });
|
||||
this.loading.set(true);
|
||||
// Prepare placeholder from router state if available for richer UI
|
||||
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
|
||||
@@ -342,14 +406,32 @@ export class WatchComponent implements OnDestroy {
|
||||
} : v);
|
||||
this.loading.set(false);
|
||||
this.loadRelatedSuggestions();
|
||||
// Update watch history with enriched title/thumbnail
|
||||
// Update watch history with enriched title/thumbnail/duration
|
||||
try {
|
||||
const provider = this.provider();
|
||||
const vid = this.videoId();
|
||||
const v = this.video();
|
||||
const title = v?.title || '';
|
||||
const thumbnail = v?.thumbnail || '';
|
||||
if (provider && (vid || id)) this.history.recordWatchStart(provider, vid || id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||
const duration = typeof v?.duration === 'number' ? v.duration : undefined;
|
||||
if (provider && (vid || id)) this.history.recordWatchStart(provider, vid || id, title, undefined, thumbnail, duration)
|
||||
.subscribe({
|
||||
next: (row) => {
|
||||
console.log('[Watch] Watch start recorded', {
|
||||
watchHistoryId: row?.id,
|
||||
lastPosition: row?.last_position_seconds,
|
||||
progress: row?.progress_seconds
|
||||
});
|
||||
this.watchHistoryId.set(row?.id || null);
|
||||
const pos = typeof row?.last_position_seconds === 'number'
|
||||
? row.last_position_seconds
|
||||
: (typeof row?.progress_seconds === 'number' ? row.progress_seconds : 0);
|
||||
console.log('[Watch] Setting start position', { position: pos });
|
||||
this.startPositionSeconds.set(pos > 0 ? pos : null);
|
||||
this.attachIframeTracking();
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
error: () => {
|
||||
@@ -376,13 +458,31 @@ export class WatchComponent implements OnDestroy {
|
||||
} : v);
|
||||
this.loading.set(false);
|
||||
this.loadRelatedSuggestions();
|
||||
// Update watch history with enriched title/thumbnail
|
||||
// Update watch history with enriched title/thumbnail/duration
|
||||
try {
|
||||
const provider = this.provider();
|
||||
const v = this.video();
|
||||
const title = v?.title || '';
|
||||
const thumbnail = v?.thumbnail || '';
|
||||
this.history.recordWatchStart(provider, id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||
const duration = typeof v?.duration === 'number' ? v.duration : undefined;
|
||||
this.history.recordWatchStart(provider, id, title, undefined, thumbnail, duration)
|
||||
.subscribe({
|
||||
next: (row) => {
|
||||
console.log('[Watch] Watch start recorded', {
|
||||
watchHistoryId: row?.id,
|
||||
lastPosition: row?.last_position_seconds,
|
||||
progress: row?.progress_seconds
|
||||
});
|
||||
this.watchHistoryId.set(row?.id || null);
|
||||
const pos = typeof row?.last_position_seconds === 'number'
|
||||
? row.last_position_seconds
|
||||
: (typeof row?.progress_seconds === 'number' ? row.progress_seconds : 0);
|
||||
console.log('[Watch] Setting start position', { position: pos });
|
||||
this.startPositionSeconds.set(pos > 0 ? pos : null);
|
||||
this.attachIframeTracking();
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
error: () => {
|
||||
@@ -393,12 +493,42 @@ export class WatchComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// Record watch start
|
||||
// Record watch start (include duration when known)
|
||||
try {
|
||||
const provider = this.provider();
|
||||
const title = this.video()?.title || '';
|
||||
const thumbnail = this.video()?.thumbnail || '';
|
||||
this.history.recordWatchStart(provider, id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||
const duration = typeof this.video()?.duration === 'number' ? this.video()!.duration : undefined;
|
||||
if (this.isLoggedIn()) {
|
||||
console.log('[Watch] User is logged in, recording watch start');
|
||||
this.history.recordWatchStart(provider, id, title, undefined, thumbnail, duration)
|
||||
.subscribe({
|
||||
next: (row) => {
|
||||
console.log('[Watch] Watch start recorded', {
|
||||
watchHistoryId: row?.id,
|
||||
lastPosition: row?.last_position_seconds,
|
||||
progress: row?.progress_seconds
|
||||
});
|
||||
this.watchHistoryId.set(row?.id || null);
|
||||
const pos = typeof row?.last_position_seconds === 'number'
|
||||
? row.last_position_seconds
|
||||
: (typeof row?.progress_seconds === 'number' ? row.progress_seconds : 0);
|
||||
console.log('[Watch] Setting start position', { position: pos });
|
||||
this.startPositionSeconds.set(pos > 0 ? pos : null);
|
||||
this.attachIframeTracking();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('[Watch] Failed to record watch start', err);
|
||||
console.error('[Watch] Error details:', {
|
||||
status: err.status,
|
||||
message: err.message,
|
||||
error: err.error
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn('[Watch] User is not logged in, skipping watch history recording');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Load like status for this video (only if logged in)
|
||||
|
||||
Reference in New Issue
Block a user