Files
NewTube/src/components/themes/provider-theme-page.component.ts
T
2026-09-29 10:39:03 -04:00

577 lines
24 KiB
TypeScript

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 { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
import { timeout } from 'rxjs/operators';
import { PagedResult } from '../../services/youtube-api.service';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
trending: ['just chatting', 'highlights'],
live: ['live', 'stream'],
gaming: ['gaming', 'playthrough'],
sports: ['sports', 'esports'],
'finance-crypto': ['finance', 'crypto'],
business: ['business', 'entrepreneur'],
tech: ['technology', 'coding'],
science: ['science', 'space'],
health: ['fitness', 'wellness'],
music: ['music', 'performance'],
podcasts: ['podcast', 'talk show'],
'movies-tv': ['movie review', 'tv discussion'],
education: ['education', 'tutorial'],
travel: ['travel vlog'],
food: ['cooking', 'food'],
'diy-makers': ['diy', 'maker'],
autos: ['car build', 'automotive'],
'nature-animals': ['outdoors', 'wildlife'],
comedy: ['comedy', 'standup'],
'art-design': ['art stream', 'drawing'],
vlogs: ['vlog', 'lifestyle'],
reviews: ['tech review', 'unboxing'],
documentary: ['history', 'documentary'],
'family-kids': ['family', 'kids'],
news: ['news', 'politics'],
};
@Component({
selector: 'app-provider-theme',
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent]
})
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
private version = 0;
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// Data state
allVideos = signal<Video[]>([]);
nextCursor = signal<string | null>(null);
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
/** Catégorie Twitch résolue (directory/category façon twitch.tv). */
twitchGame = signal<{ id: string; name: string; boxArtUrl?: string } | null>(null);
loading = signal(false);
// Continuation (cursor > first page): grid stays mounted, bottom loader only.
loadingMore = signal(false);
error = signal<string | null>(null);
// Derived state for the UI
providersList = computed(() => this.instances.providers());
videos = computed(() => this.applyFilters(this.allVideos()));
/** La page courante est-elle une page Twitch à sections (hors thème live) ? */
isTwitchSections = computed(() => this.provider() === 'twitch' && this.theme() !== 'live');
/** Listes Twitch par section (filtres hors-type appliqués), façon directory. */
private twitchFilteredNoType(): Video[] {
return this.applyFiltersWithType(this.allVideos(), 'any');
}
twitchLives = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v =>
isLiveVideo({ ...v, provider: (v.provider as string) || p }));
});
twitchVods = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
const w = { ...v, provider: (v.provider as string) || p };
if (isLiveVideo(w)) return false;
if (isShortVideo(w)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
return true;
});
});
twitchClips = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
const w = { ...v, provider: (v.provider as string) || p };
if (isLiveVideo(w)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo(w);
});
});
twitchChannels = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
if ((v as any).isLive === true) return false;
if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false;
return String(v.type || '').toLowerCase() === 'channel';
});
});
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
const t = this.type();
if (t === 'live') return 'live';
if (t === 'video' || t === 'recorded') return 'video';
if (t === 'short') return 'clip';
if (t === 'channel') return 'channel';
return 'all';
});
setTwitchTab(v: 'all' | 'live' | 'video' | 'clip' | 'channel'): void {
if (v === 'all') this.type.set('any');
else if (v === 'video') this.type.set('video');
else if (v === 'clip') this.type.set('short');
else this.type.set(v);
}
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);
this.instances.setSelectedProvider(provider);
// Thème "En direct" : activer le filtre "Live" par défaut et lever les
// filtres qui masqueraient les lives (durée/date = any).
if (theme === 'live') {
this.type.set('live');
this.duration.set('any');
this.date.set('any');
} else if (this.type() === 'live') {
// En quittant le thème live, ne pas rester bloqué sur le filtre Live.
this.type.set('any');
}
this.resetAndLoad();
});
// Re-apply filters on change
effect(() => {
this.sort();
this.duration();
this.type();
this.language();
this.date();
this.cdr.markForCheck();
});
}
private resetAndLoad() {
this.version++;
this.allVideos.set([]);
this.nextCursor.set(null);
this.twitchCursors = { live: null, vod: null, clip: null, channel: null };
this.twitchGame.set(null);
this.error.set(null);
this.loadingMore.set(false);
this.loadMore();
}
private buildQuery(provider: Provider): string {
const slug = this.theme();
const baseTokens = this.themes.tokensFor(slug);
if (provider === 'twitch') {
// Helix /search/channels et /search/categories ne matchent que des noms
// proches : une longue phrase multi-mots ("cooking food Food Cooking
// Recipe") ne retourne jamais rien. On envoie UN mot-clé court ; le
// service tente ensuite les variantes + game_id + top streams.
const twitchTokens = TWITCH_THEME_KEYWORDS[slug] || [];
const first = (twitchTokens[0] || baseTokens[0] || slug.replace(/-/g, ' ') || '').trim();
// Garde le premier mot pour maximiser le taux de match Helix.
const short = String(first).split(/[^A-Za-z0-9à-ÿ]+/).filter(Boolean)[0] || first;
return (short || first || slug.replace(/-/g, ' ')).trim();
}
const value = baseTokens.join(' ').trim();
return value || slug.replace(/-/g, ' ');
}
loadMore() {
if (this.loading() || this.loadingMore()) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
this.error.set(readiness.reason || 'Provider not ready');
return;
}
// First page (empty list) -> full loader; continuations -> bottom loader,
// grid stays mounted so scroll position is preserved.
if (this.allVideos().length > 0) this.loadingMore.set(true);
else this.loading.set(true);
this.error.set(null);
const snapshotVersion = this.version;
const provider = this.provider();
const query = this.buildQuery(provider);
const cursor = this.nextCursor();
const themeSlug = this.theme();
const isLiveTheme = themeSlug === 'live';
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) {
this.error.set('Ce fournisseur ne propose pas de directs.');
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
return;
}
let apiCall: Observable<PagedResult<Video>>;
if (isLiveTheme && provider === 'youtube') {
// Directs YouTube : search.list eventType=live (vrais lives uniquement).
apiCall = this.api.searchYouTubeLivePage('', cursor || undefined);
} else if (provider === 'twitch' && !isLiveTheme) {
// Page Twitch à sections façon directory : lives / vidéos / clips / chaînes
// chargés en parallèle avec leurs propres curseurs Helix.
this.loadTwitchSections(snapshotVersion, provider, query);
return;
} else if (provider === 'twitch') {
// Thème live : top streams (jamais vide quand Twitch est configuré).
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
} else {
apiCall = query
? this.api.searchVideosPage(query, cursor, provider)
: this.api.getTrendingPage(cursor, provider);
}
apiCall.subscribe({
next: (res: PagedResult<Video>) => {
if (snapshotVersion !== this.version) return;
// Thème live : garde-fou, ne jamais mélanger des VODs.
let items = res.items || [];
if (isLiveTheme) {
items = items.filter((v: Video) =>
isLiveVideo({ ...v, provider: (v.provider as string) || provider })
);
}
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
const newVideos = items.filter((v: Video) => !existingIds.has(v.videoId));
this.allVideos.update(current => [...current, ...newVideos]);
this.nextCursor.set(res.nextCursor || null);
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
},
error: (e) => {
if (snapshotVersion !== this.version) return;
this.error.set(e.message || 'Failed to load videos');
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
}
});
}
private applyFilters(list: Video[]): Video[] {
return this.applyFiltersWithType(list, this.type());
}
/**
* Chargement Twitch à sections (directory/category) : lives (+ catégorie),
* VODs, clips et chaînes en parallèle, chacun avec son curseur Helix.
* En continuation on ne recharge que l'onglet actif (ou tout si "Tout").
*/
private loadTwitchSections(snapshotVersion: number, provider: Provider, query: string): void {
const firstPage = this.allVideos().length === 0;
const tab = this.twitchTab();
const wantLive = firstPage || tab === 'all' || tab === 'live';
const wantVod = firstPage || tab === 'all' || tab === 'video';
const wantClip = firstPage || tab === 'all' || tab === 'clip';
const wantChannel = firstPage || tab === 'all' || tab === 'channel';
const finish = (items: Video[], next: string | null, err?: string) => {
if (snapshotVersion !== this.version) return;
if (err) this.error.set(err);
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
const fresh = items.filter(v => !existing.has(`${v.type}:${v.videoId}`));
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
this.nextCursor.set(next);
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
};
// Une section lente ne doit jamais bloquer les autres (forkJoin
// échoue dès qu'un appel erreur : chaque section est bornée à 25 s
// et retombe sur une page vide, le filet top-streams suit).
const bounded = (call: Observable<PagedResult<Video>>): Observable<PagedResult<Video>> =>
call.pipe(
timeout(25000),
catchError(() => of({ items: [], nextCursor: null } as PagedResult<Video>))
);
const fetchAll = (gameId: string | null) => {
const calls: Record<string, Observable<PagedResult<Video>>> = {};
if (wantLive) {
const liveCall = gameId
? this.api.getTwitchGameStreamsPage(gameId, this.twitchCursors.live || undefined, 12).pipe(
catchError(() => this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined)))
: this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined);
calls['live'] = bounded(liveCall);
}
if (wantVod) calls['vod'] = bounded(this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined));
if (wantClip) calls['clip'] = bounded(this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined));
if (wantChannel) calls['channel'] = bounded(this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20));
if (Object.keys(calls).length === 0) {
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
return;
}
forkJoin(calls).subscribe({
next: (res) => {
if (snapshotVersion !== this.version) return;
const r = res as Record<string, PagedResult<Video>>;
if (r['live']) this.twitchCursors.live = r['live'].nextCursor || null;
if (r['vod']) this.twitchCursors.vod = r['vod'].nextCursor || null;
if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null;
if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null;
const items = Object.values(r).flatMap(x => x?.items || []);
// Filets de sécurité : si VODs+clips vides et aucun live, compléter
// par top streams (repli serveur inclus), puis par la recherche
// serveur unifiée (/api/search?providers=tw).
const hasContent = items.length > 0;
if (!hasContent && firstPage) {
this.api.getTwitchTopStreamsPage(null).subscribe({
next: (top) => {
const all = [...items, ...(top.items || [])];
if (all.length) {
finish(all, top.nextCursor || null);
return;
}
this.api.searchTwitchViaServer(query, 24).subscribe({
next: (srv) => finish([...all, ...(srv.items || [])], srv.nextCursor || null),
error: () => finish(all, null),
});
},
error: () => finish(items, null),
});
return;
}
const next = this.twitchCursors.live || this.twitchCursors.vod || this.twitchCursors.clip || this.twitchCursors.channel;
finish(items, next);
},
error: (e) => finish([], null, e?.message || 'Failed to load videos'),
});
};
if (firstPage) {
// Première page : résoudre la catégorie Twitch pour des lives
// thématiques, SANS bloquer l'affichage (4 s max, la recherche de
// catégorie séquentielle prenait plusieurs secondes à elle seule).
this.api.searchTwitchCategory(query).pipe(
timeout(4000),
catchError(() => of(null))
).subscribe({
next: (game) => {
if (snapshotVersion !== this.version) return;
if (game) this.twitchGame.set(game);
fetchAll(game?.id || null);
},
error: () => {
if (snapshotVersion !== this.version) return;
fetchAll(null);
},
});
} else {
fetchAll(this.twitchGame()?.id || null);
}
}
private applyFiltersWithType(list: Video[], typeOverride: 'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'): Video[] {
let arr = [...list];
const isLiveTheme = this.theme() === 'live';
const durationFilter = this.duration();
// Les lives n'ont pas de durée connue (0) : le filtre durée ne s'applique pas sur le thème live.
if (durationFilter !== 'any' && !isLiveTheme) {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
if (durationFilter === 'medium') return dur >= 240 && dur < 1200; // 4-20 min
if (durationFilter === 'long') return dur >= 1200; // > 20 min
return true;
});
}
const typeFilter = typeOverride;
if (typeFilter !== 'any') {
const p = this.provider();
arr = arr.filter(v => {
const withProvider = { ...v, provider: (v.provider as string) || p };
if (typeFilter === 'video') return !isShortVideo(withProvider) && !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'video' || v.type === 'recorded');
if (typeFilter === 'live') return isLiveVideo(withProvider);
if (typeFilter === 'recorded') return !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider)));
// Shorts / Clips : classification partagée + kind='clip' (durée parfois absente).
if (typeFilter === 'short') {
if (isLiveVideo(withProvider)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo(withProvider);
}
// Chaînes : hors-ligne uniquement (les lives restent dans "Direct").
if (typeFilter === 'channel') {
if ((v as any).isLive === true) return false;
if (isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel') return false;
return String(v.type || '').toLowerCase() === 'channel';
}
return true;
});
} else if (this.theme() === 'live') {
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
const p = this.provider();
arr = arr.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || p }));
}
const langFilter = this.language();
if (langFilter !== 'any') {
// Simple heuristic, can be improved
const isFr = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
const isEn = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`.toLowerCase();
if (langFilter === 'fr') return isFr(text);
if (langFilter === 'en') return isEn(text) && !isFr(text);
return true;
});
}
const dateFilter = this.date();
if (dateFilter !== 'any') {
const now = Date.now();
const cutoff = {
day: now - 24 * 3600 * 1000,
week: now - 7 * 24 * 3600 * 1000,
month: now - 30 * 24 * 3600 * 1000,
year: now - 365 * 24 * 3600 * 1000,
}[dateFilter] || 0;
if (cutoff > 0) {
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
}
}
const sortOrder = this.sort();
if (sortOrder === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (sortOrder === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (sortOrder === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
return arr;
}
changeProvider(p: any) {
const provider = String(p) as Provider;
this.instances.setSelectedProvider(provider);
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); }
onTypeChange(val: any) { this.type.set(val as any); }
// Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant.
contentFilter = computed<'all' | 'videos' | 'shorts'>(() => {
const t = this.type();
if (t === 'short') return 'shorts';
if (t === 'video') return 'videos';
return 'all';
});
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
}
kindOf(v: Video): string {
const p = this.provider();
return classifyVideo({ ...v, provider: (v.provider as string) || p });
}
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
isLiveCard(v: Video): boolean {
if ((v as any).isLive === true) return true;
if (String(v.type || '').toLowerCase() === 'live') return true;
if (String(v.type || '').toLowerCase() === 'channel') return false;
const p = this.provider();
return isLiveVideo({ ...v, provider: (v.provider as string) || p })
&& String(v.type || '').toLowerCase() !== 'channel';
}
isChannelCard(v: Video): boolean {
if ((v as any).isLive === true) return false;
return String(v.type || '').toLowerCase() === 'channel';
}
isClipCard(v: Video): boolean {
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
const p = this.provider();
return isShortVideo({ ...v, provider: (v.provider as string) || p });
}
/** Affichage "Tout" Twitch : sections empilées façon directory. */
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
shortsCount(): number {
const p = this.provider();
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
}
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
retry() {
this.error.set(null);
this.loadMore();
}
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') {
// Clips : embed dédié ?clip=<slug> (jamais ?channel=, le slug n'est pas un login).
if (String((v as any).kind || '').toLowerCase() === 'clip') {
qp.clip = v.videoId;
return qp;
}
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
// ?channel= exige le login (non numérique) ; un id numérique = VOD.
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
}
}
return qp;
}
trackByVideo(_idx: number, v: Video) { return `${v.type}:${v.videoId}` || v.url; }
ngOnDestroy(): void {
this.version++; // Invalidate any pending requests
}
}