chore: update Angular cache and TypeScript build info
This commit is contained in:
parent
b835bfcdbd
commit
3d78d0aef9
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
BIN
db/newtube.db
BIN
db/newtube.db
Binary file not shown.
@ -193,7 +193,7 @@ export function deleteAllSearchHistory(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Watch History --------------------
|
// -------------------- Watch History --------------------
|
||||||
export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
|
export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds }) {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const watched_at = watchedAt || now;
|
const watched_at = watchedAt || now;
|
||||||
// Insert or update on unique (user_id, provider, video_id)
|
// Insert or update on unique (user_id, provider, video_id)
|
||||||
@ -204,9 +204,9 @@ export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail
|
|||||||
thumbnail=COALESCE(excluded.thumbnail, watch_history.thumbnail),
|
thumbnail=COALESCE(excluded.thumbnail, watch_history.thumbnail),
|
||||||
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
||||||
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
||||||
last_position_seconds=excluded.last_position_seconds,
|
last_position_seconds=COALESCE(excluded.last_position_seconds, watch_history.last_position_seconds),
|
||||||
last_watched_at=excluded.last_watched_at`).run(
|
last_watched_at=excluded.last_watched_at`).run(
|
||||||
cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
|
cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now
|
||||||
);
|
);
|
||||||
// Return the row id
|
// Return the row id
|
||||||
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
||||||
|
@ -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({
|
@Component({
|
||||||
selector: 'app-video-player',
|
selector: 'app-video-player',
|
||||||
@ -6,8 +7,12 @@ import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, input } from
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class VideoPlayerComponent {
|
export class VideoPlayerComponent implements AfterViewInit, OnDestroy {
|
||||||
videoSource = input.required<string>();
|
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>;
|
@ViewChild('player', { static: false }) playerRef?: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
@ -15,6 +20,90 @@ export class VideoPlayerComponent {
|
|||||||
return this.playerRef?.nativeElement ?? 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 {
|
togglePlayPause(): void {
|
||||||
const v = this.el; if (!v) return;
|
const v = this.el; if (!v) return;
|
||||||
if (v.paused) v.play(); else v.pause();
|
if (v.paused) v.play(); else v.pause();
|
||||||
|
@ -28,12 +28,16 @@
|
|||||||
<div class="lg:w-2/3">
|
<div class="lg:w-2/3">
|
||||||
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
|
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
|
||||||
@if (embedUrl(); as src) {
|
@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"
|
referrerpolicy="origin"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
allowfullscreen></iframe>
|
allowfullscreen></iframe>
|
||||||
} @else {
|
} @else {
|
||||||
<app-video-player [videoSource]="videoSource()"></app-video-player>
|
<app-video-player
|
||||||
|
[videoSource]="videoSource()"
|
||||||
|
[startPositionSeconds]="startPositionSeconds()"
|
||||||
|
[watchHistoryId]="watchHistoryId()"
|
||||||
|
></app-video-player>
|
||||||
}
|
}
|
||||||
</div>
|
</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 { HttpClient } from '@angular/common/http';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
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 { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||||
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -22,7 +23,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, VideoPlayerComponent, RouterLink]
|
imports: [CommonModule, VideoPlayerComponent, RouterLink]
|
||||||
})
|
})
|
||||||
export class WatchComponent implements OnDestroy {
|
export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private apiService = inject(YoutubeApiService);
|
private apiService = inject(YoutubeApiService);
|
||||||
@ -34,7 +35,10 @@ export class WatchComponent implements OnDestroy {
|
|||||||
private likes = inject(LikesService);
|
private likes = inject(LikesService);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private http = inject(HttpClient);
|
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
|
// Choose correct API base: use dev proxy when UI runs on a different port than the backend
|
||||||
private apiBase(): string {
|
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);
|
video = signal<VideoDetail | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
showFullDescription = signal(false);
|
showFullDescription = signal(false);
|
||||||
@ -68,7 +111,11 @@ export class WatchComponent implements OnDestroy {
|
|||||||
geminiReady = this.geminiService.isReady();
|
geminiReady = this.geminiService.isReady();
|
||||||
geminiReason = this.geminiService.readinessReason();
|
geminiReason = this.geminiService.readinessReason();
|
||||||
// Auth status for gating download UI
|
// 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)
|
// Quality selection (progressive streams only to ensure audio present)
|
||||||
selectedQuality = signal<string | null>(null);
|
selectedQuality = signal<string | null>(null);
|
||||||
progressiveStreams = computed(() => (this.video()?.videoStreams || []).filter(s => !s.videoOnly));
|
progressiveStreams = computed(() => (this.video()?.videoStreams || []).filter(s => !s.videoOnly));
|
||||||
@ -150,6 +197,10 @@ export class WatchComponent implements OnDestroy {
|
|||||||
liked = signal<boolean>(false);
|
liked = signal<boolean>(false);
|
||||||
likeBusy = 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
|
// Route params for building embeds
|
||||||
private videoId = signal<string>('');
|
private videoId = signal<string>('');
|
||||||
private odyseeSlug = signal<string | null>(null);
|
private odyseeSlug = signal<string | null>(null);
|
||||||
@ -161,17 +212,23 @@ export class WatchComponent implements OnDestroy {
|
|||||||
const p = this.provider();
|
const p = this.provider();
|
||||||
const ch = this.twitchChannel();
|
const ch = this.twitchChannel();
|
||||||
const slug = this.odyseeSlug();
|
const slug = this.odyseeSlug();
|
||||||
|
const start = Math.max(0, this.startPositionSeconds() || 0);
|
||||||
if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
|
if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
|
||||||
try {
|
try {
|
||||||
const host = (location && location.hostname) ? location.hostname : 'localhost';
|
const host = (location && location.hostname) ? location.hostname : 'localhost';
|
||||||
if (p === 'youtube') {
|
if (p === 'youtube') {
|
||||||
const origin = window.location.origin || 'https://' + (window.location.hostname || 'localhost');
|
const origin = window.location.origin || 'https://' + (window.location.hostname || 'localhost');
|
||||||
const ref = typeof window !== 'undefined' ? window.location.href : '';
|
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);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
if (p === 'dailymotion') {
|
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);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
if (p === 'twitch') {
|
if (p === 'twitch') {
|
||||||
@ -187,7 +244,9 @@ export class WatchComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
if (p === 'peertube') {
|
if (p === 'peertube') {
|
||||||
const inst = this.instances.activePeerTubeInstance();
|
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);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
if (p === 'odysee') {
|
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 {
|
ngOnDestroy(): void {
|
||||||
this.routeSubscription.unsubscribe();
|
this.routeSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
@ -282,6 +345,7 @@ export class WatchComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadVideo(id: string) {
|
loadVideo(id: string) {
|
||||||
|
console.log('[Watch] Loading video', { id });
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
// Prepare placeholder from router state if available for richer UI
|
// Prepare placeholder from router state if available for richer UI
|
||||||
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
|
const stateVideo = (history.state && (history.state as any).video) as Video | undefined;
|
||||||
@ -342,14 +406,32 @@ export class WatchComponent implements OnDestroy {
|
|||||||
} : v);
|
} : v);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loadRelatedSuggestions();
|
this.loadRelatedSuggestions();
|
||||||
// Update watch history with enriched title/thumbnail
|
// Update watch history with enriched title/thumbnail/duration
|
||||||
try {
|
try {
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
const vid = this.videoId();
|
const vid = this.videoId();
|
||||||
const v = this.video();
|
const v = this.video();
|
||||||
const title = v?.title || '';
|
const title = v?.title || '';
|
||||||
const thumbnail = v?.thumbnail || '';
|
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 {}
|
} catch {}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@ -376,13 +458,31 @@ export class WatchComponent implements OnDestroy {
|
|||||||
} : v);
|
} : v);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loadRelatedSuggestions();
|
this.loadRelatedSuggestions();
|
||||||
// Update watch history with enriched title/thumbnail
|
// Update watch history with enriched title/thumbnail/duration
|
||||||
try {
|
try {
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
const v = this.video();
|
const v = this.video();
|
||||||
const title = v?.title || '';
|
const title = v?.title || '';
|
||||||
const thumbnail = v?.thumbnail || '';
|
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 {}
|
} catch {}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@ -393,12 +493,42 @@ export class WatchComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record watch start
|
// Record watch start (include duration when known)
|
||||||
try {
|
try {
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
const title = this.video()?.title || '';
|
const title = this.video()?.title || '';
|
||||||
const thumbnail = this.video()?.thumbnail || '';
|
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 {}
|
} catch {}
|
||||||
|
|
||||||
// Load like status for this video (only if logged in)
|
// Load like status for this video (only if logged in)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of, tap } from 'rxjs';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
export interface SearchHistoryItem {
|
export interface SearchHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,7 +25,17 @@ export interface WatchHistoryItem {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class HistoryService {
|
export class HistoryService {
|
||||||
constructor(private http: HttpClient) {}
|
private http = inject(HttpClient);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
|
private apiBase(): string {
|
||||||
|
try {
|
||||||
|
const port = window?.location?.port || '';
|
||||||
|
return port && port !== '4000' ? '/proxy/api' : '/api';
|
||||||
|
} catch {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Search ---
|
// --- Search ---
|
||||||
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
|
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
|
||||||
@ -43,19 +54,34 @@ export class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Watch ---
|
// --- Watch ---
|
||||||
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null): Observable<WatchHistoryItem> {
|
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null, durationSeconds?: number): Observable<WatchHistoryItem> {
|
||||||
return this.http.post<WatchHistoryItem>(
|
return this.http.post<WatchHistoryItem>(
|
||||||
'/proxy/api/user/history/watch',
|
'/proxy/api/user/history/watch',
|
||||||
{ provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null },
|
{ provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null, durationSeconds },
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWatchProgress(id: string, progressSeconds: number, lastPositionSeconds?: number): Observable<WatchHistoryItem> {
|
updateWatchProgress(id: string, progress_seconds: number, last_position_seconds: number): Observable<any> {
|
||||||
return this.http.patch<WatchHistoryItem>(
|
if (!this.auth.currentUser()) {
|
||||||
`/proxy/api/user/history/watch/${encodeURIComponent(id)}`,
|
console.warn('[HistoryService] Not authenticated, skipping watch progress update');
|
||||||
{ progressSeconds, lastPositionSeconds },
|
return of(null);
|
||||||
{ withCredentials: true }
|
}
|
||||||
|
console.log('[HistoryService] Updating watch progress', { id, progress_seconds, last_position_seconds });
|
||||||
|
return this.http.patch(`${this.apiBase()}/user/history/watch/${id}`, {
|
||||||
|
progressSeconds: progress_seconds,
|
||||||
|
lastPositionSeconds: last_position_seconds
|
||||||
|
}, { withCredentials: true }).pipe(
|
||||||
|
tap({
|
||||||
|
next: (response) => {
|
||||||
|
console.log('[HistoryService] Watch progress updated successfully', response);
|
||||||
|
console.log('[HistoryService] Updated watch progress', { id, progress_seconds, last_position_seconds });
|
||||||
|
},
|
||||||
|
error: (e: any) => {
|
||||||
|
console.error('[HistoryService] Failed to update watch progress', e);
|
||||||
|
console.error('[HistoryService] Error updating watch progress', { id, progress_seconds, last_position_seconds, error: e });
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
235
src/services/iframe-progress.service.ts
Normal file
235
src/services/iframe-progress.service.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
|
function loadScriptOnce(src: string, globalFlag: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const anyWin: any = window as any;
|
||||||
|
if (anyWin[globalFlag]) { resolve(); return; }
|
||||||
|
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
|
||||||
|
if (existing) { existing.addEventListener('load', () => resolve()); existing.addEventListener('error', (e) => reject(e)); return; }
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.async = true;
|
||||||
|
s.src = src;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = (e) => reject(e);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class IframeProgressService {
|
||||||
|
private ytIntervals = new Map<string, any>();
|
||||||
|
private peertubeHandlers = new Map<string, (ev: MessageEvent) => void>();
|
||||||
|
private lastSentAt = new Map<string, number>();
|
||||||
|
private attached = new Map<string, HTMLIFrameElement>();
|
||||||
|
private dmHandlers = new Map<string, (ev: MessageEvent) => void>();
|
||||||
|
|
||||||
|
constructor(private history: HistoryService) {}
|
||||||
|
|
||||||
|
async attach(provider: string, iframe: HTMLIFrameElement | null, watchId: string | null, startSeconds?: number | null) {
|
||||||
|
console.log('[IframeProgress] attach called', { provider, hasIframe: !!iframe, watchId, startSeconds });
|
||||||
|
if (!iframe || !watchId) {
|
||||||
|
console.warn('[IframeProgress] Missing iframe or watchId, cannot attach', { hasIframe: !!iframe, watchId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Always allow re-attachment. We will clean up provider-specific handlers/intervals first.
|
||||||
|
const p = (provider || '').toLowerCase();
|
||||||
|
try {
|
||||||
|
// Debug trace
|
||||||
|
try { console.debug('[IframeProgress] attach', { provider: p, watchId, startSeconds }); } catch {}
|
||||||
|
if (p === 'youtube') {
|
||||||
|
await this.attachYouTube(iframe, watchId, startSeconds || 0);
|
||||||
|
} else if (p === 'dailymotion') {
|
||||||
|
await this.attachDailymotion(iframe, watchId, startSeconds || 0);
|
||||||
|
} else if (p === 'peertube') {
|
||||||
|
this.attachPeerTube(iframe, watchId);
|
||||||
|
} else if (p === 'twitch') {
|
||||||
|
// TODO: Twitch JS embed API could be integrated; limited time APIs.
|
||||||
|
} else if (p === 'odysee' || p === 'rumble') {
|
||||||
|
// No stable public APIs for progress; resume is handled via URL params where supported.
|
||||||
|
}
|
||||||
|
this.attached.set(watchId, iframe);
|
||||||
|
} catch {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachYouTube(iframe: HTMLIFrameElement, watchId: string, start: number) {
|
||||||
|
console.log('[IframeProgress] Attaching YouTube player', { watchId, start });
|
||||||
|
// Clear existing interval if any (idempotent attach)
|
||||||
|
const prev = this.ytIntervals.get(watchId);
|
||||||
|
if (prev) { try { clearInterval(prev); } catch {} this.ytIntervals.delete(watchId); }
|
||||||
|
const win: any = window as any;
|
||||||
|
console.log('[IframeProgress] Loading YouTube Iframe API');
|
||||||
|
try {
|
||||||
|
await loadScriptOnce('https://www.youtube.com/iframe_api', 'YT');
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.warn('[IframeProgress] YouTube Iframe API load timeout');
|
||||||
|
reject(new Error('YouTube Iframe API load timeout'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (win.YT && win.YT.Player) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.log('[IframeProgress] YouTube Iframe API already loaded');
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[IframeProgress] Waiting for YouTube Iframe API to be ready');
|
||||||
|
(win as any).onYouTubeIframeAPIReady = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.log('[IframeProgress] YouTube Iframe API ready');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[IframeProgress] Failed to load YouTube Iframe API', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!iframe.id) {
|
||||||
|
iframe.id = 'yt-embed-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
console.log('[IframeProgress] Assigned ID to iframe:', iframe.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[IframeProgress] Creating YT.Player instance');
|
||||||
|
const player = new win.YT.Player(iframe, {
|
||||||
|
host: 'https://www.youtube-nocookie.com',
|
||||||
|
playerVars: { origin: window.location.origin || undefined },
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
console.log('[IframeProgress] YouTube player ready');
|
||||||
|
if (start && start > 0) {
|
||||||
|
try {
|
||||||
|
console.log(`[IframeProgress] Seeking to ${start} seconds`);
|
||||||
|
player.seekTo(start, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[IframeProgress] Failed to seek', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[IframeProgress] Starting progress tracking interval');
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
try {
|
||||||
|
const t = Math.floor(player.getCurrentTime());
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this.lastSentAt.get(watchId) || 0;
|
||||||
|
|
||||||
|
if (now - last >= 3000) {
|
||||||
|
console.log(`[IframeProgress] Sending progress update for ${watchId}: ${t}s`);
|
||||||
|
this.lastSentAt.set(watchId, now);
|
||||||
|
this.history.updateWatchProgress(watchId, t, t).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
console.log(`[IframeProgress] Progress updated for ${watchId}:`, response);
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
console.error(`[IframeProgress] Failed to update progress for ${watchId}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 1000);
|
||||||
|
this.ytIntervals.set(watchId, interval);
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (!document.contains(iframe)) {
|
||||||
|
clearInterval(interval);
|
||||||
|
if (this.attached.get(watchId) === iframe) this.attached.delete(watchId);
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(document.body, { childList: true, subtree: true });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachDailymotion(iframe: HTMLIFrameElement, watchId: string, start: number) {
|
||||||
|
console.log('[IframeProgress] Attaching Dailymotion player (postMessage only)', { watchId, start });
|
||||||
|
if (!iframe.id) {
|
||||||
|
iframe.id = 'dm-embed-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
console.log('[IframeProgress] Assigned ID to Dailymotion iframe:', iframe.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) postMessage fallback (broadly log and handle DM message shapes)
|
||||||
|
// Remove existing handler for this watchId to avoid duplicates
|
||||||
|
const prevHandler = this.dmHandlers.get(watchId);
|
||||||
|
if (prevHandler) {
|
||||||
|
try { window.removeEventListener('message', prevHandler); } catch {}
|
||||||
|
}
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const origin = String(event.origin || '');
|
||||||
|
if (!/dailymotion\.com|dmcdn\.net/.test(origin)) return;
|
||||||
|
let data: any = event.data;
|
||||||
|
// Log the raw event once in a while to understand the shape
|
||||||
|
try { console.debug('[IframeProgress] [DM PM] message', { origin, data }); } catch {}
|
||||||
|
if (!data) return;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try { data = JSON.parse(data); } catch { /* some messages are plain strings */ }
|
||||||
|
}
|
||||||
|
// Known shapes to extract event name and current time
|
||||||
|
const ev = (data && (data.event || data.name || data.type)) || '';
|
||||||
|
const timeVal = (data && (data.time ?? data.currentTime ?? data.current_time ?? data.position)) as any;
|
||||||
|
if ((ev === 'timeupdate' || ev === 'progress' || ev === 'video_time_change' || ev === 'play_progress') && typeof timeVal === 'number') {
|
||||||
|
const t = Math.floor(timeVal);
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this.lastSentAt.get(watchId) || 0;
|
||||||
|
if (now - last >= 3000) {
|
||||||
|
console.log(`[IframeProgress] [DM PM] progress -> ${t}s for ${watchId}`);
|
||||||
|
this.lastSentAt.set(watchId, now);
|
||||||
|
this.history.updateWatchProgress(watchId, t, t).subscribe({ next: () => {}, error: (e) => { try { console.warn('[IframeProgress] DM PM update failed', e); } catch {} } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
this.dmHandlers.set(watchId, onMessage);
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (!document.contains(iframe)) {
|
||||||
|
window.removeEventListener('message', onMessage);
|
||||||
|
this.dmHandlers.delete(watchId);
|
||||||
|
if (this.attached.get(watchId) === iframe) this.attached.delete(watchId);
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachPeerTube(iframe: HTMLIFrameElement, watchId: string) {
|
||||||
|
console.log('[IframeProgress] Attaching PeerTube message listener', { watchId });
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
console.log('[IframeProgress] Received PeerTube message', event);
|
||||||
|
const data: any = event.data;
|
||||||
|
if (!data || typeof data !== 'object') return;
|
||||||
|
if (data.event === 'timeupdate' && typeof data.currentTime === 'number') {
|
||||||
|
const t = Math.floor(data.currentTime);
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this.lastSentAt.get(watchId) || 0;
|
||||||
|
|
||||||
|
if (now - last >= 3000) {
|
||||||
|
console.log(`[IframeProgress] Sending PeerTube progress update for ${watchId}: ${t}s`);
|
||||||
|
this.lastSentAt.set(watchId, now);
|
||||||
|
this.history.updateWatchProgress(watchId, t, t).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
console.log(`[IframeProgress] PeerTube progress updated for ${watchId}:`, response);
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
console.error(`[IframeProgress] Failed to update PeerTube progress for ${watchId}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
this.peertubeHandlers.set(watchId, handler);
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (!document.contains(iframe)) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
if (this.attached.get(watchId) === iframe) this.attached.delete(watchId);
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user