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 = { 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('youtube'); theme = signal('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([]); nextCursor = signal(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(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>; 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