first commit

This commit is contained in:
2025-09-14 23:05:30 -04:00
commit 2866d74b32
122 changed files with 95474 additions and 0 deletions
@@ -0,0 +1,375 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, inject, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { InstanceService, Provider } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { TranslatePipe } from '../../pipes/translate.pipe';
interface SectionState {
key: 'recorded' | 'videos' | 'categories';
titleKey: string;
items: Video[];
nextCursor: string | null;
loading: boolean;
error: string | null;
visibleCount: number; // how many items are currently shown
}
@Component({
selector: 'app-provider-theme',
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private instances = inject(InstanceService);
themes = inject(ThemesService);
private api = inject(YoutubeApiService);
private cdr = inject(ChangeDetectorRef);
provider = signal<Provider>('youtube');
theme = signal<string>('trending');
// Guard to ignore stale async responses after route/theme/provider changes
private version = 0;
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// UI
providersList = computed(() => this.instances.providers());
categories = computed(() => this.themes.categoriesFor(this.theme()));
// Sections (Live removed on provider pages)
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
constructor() {
this.route.paramMap.subscribe(pm => {
const provider = (pm.get('provider') as Provider) || 'youtube';
const theme = pm.get('theme') || 'trending';
this.provider.set(provider);
this.theme.set(theme);
// Keep global provider in sync so header search uses the current context
try { this.instances.setSelectedProvider(provider); } catch {}
this.resetAll();
// Increment version so in-flight requests from previous state are ignored
this.version++;
this.loadInitial();
this.cdr.markForCheck();
});
// Prefetch recalculation on resize (affects columns/rows thresholds)
this.handleResize = () => {
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
};
try { window.addEventListener('resize', this.handleResize); } catch {}
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
effect(() => {
const _s = this.sort();
const _d = this.duration();
const _l = this.language();
const _dt = this.date();
const apply = () => {
// Reset visible window to ensure top results reflect new sort/filter instantly
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
this.videos.visibleCount = Math.max(20, this.videos.visibleCount);
this.recorded.items = this.applyFilters(this.recorded.items);
this.videos.items = this.applyFilters(this.videos.items);
// After filtering, re-evaluate if we need to prefetch more
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
try {
clearTimeout((this as any).__fltTimer);
} catch {}
(this as any).__fltTimer = setTimeout(apply, 150);
}, { allowSignalWrites: true });
}
private resetAll() {
for (const s of [this.recorded, this.videos]) {
s.items = []; s.nextCursor = null; s.loading = false; s.error = null; s.visibleCount = 20;
}
this.cdr.markForCheck();
}
private baseTokens(): string[] {
const t = this.theme();
if (t === 'trending') return [];
if (t === 'live') return ['live'];
return this.themes.tokensFor(t);
}
private buildQueryFor(section: SectionState): string {
const tokens = [...this.baseTokens()];
if (section.key === 'recorded') {
const p = this.provider();
// Only providers that benefit from a 'replay' search hint
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
// For other providers, prefer trending (empty query) to avoid bad/no results
}
return tokens.join(' ').trim();
}
private applyFilters(list: Video[]): Video[] {
let arr = list.slice();
const d = this.duration();
if (d !== 'any') {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (d === 'short') return dur > 0 && dur < 600;
if (d === 'medium') return dur >= 600 && dur < 1800;
if (d === 'long') return dur >= 1800;
return true;
});
}
// Language heuristic (very lightweight)
const lang = this.language();
if (lang !== 'any') {
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`;
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
const en = isEnWord(text);
return lang === 'fr' ? fr && !en : en && !fr;
});
}
// Date filter based on uploaded timestamp if available
const dateOpt = this.date();
if (dateOpt !== 'any') {
const now = Date.now();
const cutoff = (() => {
if (dateOpt === 'day') return now - 24 * 3600_000;
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
return 0;
})();
if (cutoff > 0) {
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
}
}
const s = this.sort();
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
return arr;
}
changeProvider(p: any) {
const provider = String(p) as Provider;
// Sync the global provider so header search reflects the current context immediately
try { this.instances.setSelectedProvider(provider); } catch {}
this.router.navigate(['/p', provider, 't', this.theme()]);
}
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
loadInitial() {
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
const msg = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
this.recorded.error = msg; this.recorded.loading = false;
this.videos.error = msg; this.videos.loading = false;
return;
}
this.loadMore(this.recorded);
this.loadMore(this.videos);
}
loadMore(section: SectionState) {
if (section.loading) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
section.error = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
section.loading = false;
return;
}
section.loading = true;
this.cdr.markForCheck();
const provider = this.provider();
const query = this.buildQueryFor(section);
const snapshotVersion = this.version;
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
section.loading = false;
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
return;
}
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
// Ignore responses from a previous state
if (snapshotVersion !== this.version) return;
// Deduplicate by videoId or URL before applying filters
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
const beforeLen = section.items.length;
const merged: Video[] = [...section.items];
const incoming = res.items || [];
const beforeKeys = existing.size;
for (const v of res.items || []) {
const key = v.videoId || v.url || '';
if (!existing.has(key)) { existing.add(key); merged.push(v); }
}
const afterKeys = existing.size;
const added = Math.max(0, afterKeys - beforeKeys);
const duplicates = Math.max(0, (incoming.length || 0) - added);
section.items = this.applyFilters(merged);
section.nextCursor = res.nextCursor || null;
section.loading = false;
if (incoming.length === 0) {
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
}
if (!res.nextCursor) {
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
}
if (duplicates > 0) {
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
}
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
if (provider === 'youtube') {
try {
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
const unique = Array.from(new Set(wantIds));
if (unique.length) {
this.api.getYouTubeDurations(unique).subscribe({
next: (mapDur) => {
if (snapshotVersion !== this.version) return;
if (!mapDur) return;
// Update durations in-place
for (const v of section.items) {
const id = v.videoId || '';
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
}
// Re-apply filters/sorts that depend on duration
section.items = this.applyFilters(section.items);
this.checkPrefetch(section);
this.cdr.markForCheck();
},
error: () => {}
});
}
} catch {}
}
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
this.checkPrefetch(section);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
const onErr = (e?: any) => {
// Specific message for quota exceeded on YouTube
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
const status = e?.status;
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
} else {
section.error = 'Erreur de chargement';
}
section.loading = false;
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
if (query) {
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
} else {
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
}
}
// Increase visible items by 20; fetch the next page if we need more data
showMore(section: SectionState) {
const target = section.visibleCount + 20;
section.visibleCount = target;
// If we don't have enough items yet and there is a next page, fetch it
if (section.items.length < target && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
this.loadMore(section);
}
// Additionally, run logic-based prefetch check in case of edge conditions
this.checkPrefetch(section);
this.cdr.markForCheck();
}
// Helpers for watch params
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.provider();
const qp: any = { p };
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
return qp;
}
// trackBy to reduce DOM churn
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
// Manual retry helper
retry(section: SectionState) {
section.error = null;
this.loadMore(section);
}
// --- Prefetch helpers ---
private handleResize: (() => void) | null = null;
private currentCols(): number {
try {
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
return 2;
} catch {
return 4; // safe default
}
}
private checkPrefetch(section: SectionState) {
const cols = this.currentCols();
const threshold = cols * 2; // ~2 rows
const remaining = section.items.length - section.visibleCount;
if (remaining <= threshold && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
this.loadMore(section);
}
}
ngOnDestroy(): void {
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
}
}