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 { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind'; import { map, of, switchMap, catchError, Observable } from 'rxjs'; 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] }) 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'>('any'); language = signal<'any' | 'en' | 'fr'>('any'); date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any'); // Data state allVideos = signal([]); nextCursor = signal(null); loading = signal(false); error = signal(null); // Derived state for the UI providersList = computed(() => this.instances.providers()); videos = computed(() => this.applyFilters(this.allVideos())); 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); 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.error.set(null); this.loadMore(); } private buildQuery(provider: Provider): string { const slug = this.theme(); const baseTokens = this.themes.tokensFor(slug); if (provider === 'twitch') { const twitchTokens = TWITCH_THEME_KEYWORDS[slug] || []; const merged = [...new Set([...twitchTokens, ...baseTokens])]; const value = merged.join(' ').trim(); if (value) return value; if (twitchTokens.length) return twitchTokens.join(' '); return slug.replace(/-/g, ' '); } const value = baseTokens.join(' ').trim(); return value || slug.replace(/-/g, ' '); } loadMore() { if (this.loading()) return; const readiness = this.instances.getProviderReadiness(this.provider()); if (!readiness.ready) { this.error.set(readiness.reason || 'Provider not ready'); return; } 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'; let apiCall: Observable>; if (provider === 'twitch') { if (isLiveTheme) { // Thème live : top streams (jamais vide quand Twitch est configuré). apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined); } else { // Thème classique : VODs puis clips, puis top streams en filet de sécurité. apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe( switchMap((res) => { if ((res?.items?.length || 0) >= 8) { return of(res); } return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe( map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })), switchMap((merged: any) => { if (((merged?.items?.length) || 0) >= 4) return of(merged); return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe( map((top) => { const seen = new Set((merged?.items || []).map((v: Video) => v.videoId)); const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId)); return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null }; }), // En cas d'échec du fallback, garder au moins VODs+clips. catchError(() => of(merged)) ); }) ); }) ); } } else { apiCall = query ? this.api.searchVideosPage(query, cursor, provider) : this.api.getTrendingPage(cursor, provider); } apiCall.subscribe({ next: (res: PagedResult