import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ThemesService } from '../../services/themes.service'; import { YoutubeApiService } from '../../services/youtube-api.service'; import { Provider, InstanceService } from '../../services/instance.service'; import { Video } from '../../models/video.model'; import { TranslatePipe } from '../../pipes/translate.pipe'; import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component'; import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind'; interface ProviderBlock { provider: Provider; label: string; loading: boolean; error: string | null; items: Video[]; } @Component({ selector: 'app-theme-page', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './theme-page.component.html', imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent] }) export class ThemePageComponent { private route = inject(ActivatedRoute); themes = inject(ThemesService); private api = inject(YoutubeApiService); private instances = inject(InstanceService); // Show all supported providers on Tendances private providersToShow: Provider[] = ['youtube', 'twitch', 'dailymotion', 'peertube', 'rumble', 'odysee']; readonly themeSlug = signal('trending'); readonly themeLabel = computed(() => this.themes.bySlug(this.themeSlug())?.label || this.themeSlug()); blocks = signal([]); // Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle). readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all'); setContentFilter(v: 'all' | 'videos' | 'shorts'): void { this.contentFilter.set(v); } /** Fournisseurs capables de fournir de vrais directs. */ private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch']; isLiveTheme(): boolean { return this.themeSlug() === 'live'; } isLiveProvider(p: Provider): boolean { return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p); } /** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */ filteredItems(b: ProviderBlock): Video[] { let items = b.items; // Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live). if (this.themeSlug() === 'live') { items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider })); } const f = this.contentFilter(); if (f === 'all') return items; return items.filter(v => f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) ); } kindOf(v: Video, provider: Provider): string { return classifyVideo({ ...v, provider: (v.provider as string) || provider }); } /** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */ isLiveCard(v: Video, provider: Provider): 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; return isLiveVideo({ ...v, provider: (v.provider as string) || provider }) && 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, provider: Provider): boolean { if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false; if (String((v as any).kind || '').toLowerCase() === 'clip') return true; return isShortVideo({ ...v, provider: (v.provider as string) || provider }); } shortsCount(b: ProviderBlock): number { return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length; } constructor() { this.route.paramMap.subscribe(pm => { const slug = pm.get('theme') || pm.get('slug') || 'trending'; this.themeSlug.set(slug); this.loadAllBlocks(); }); } private loadAllBlocks() { const slug = this.themeSlug(); const blocks: ProviderBlock[] = this.providersToShow.map(p => ({ provider: p, label: this.labelFor(p), loading: true, error: null, items: [] })); this.blocks.set(blocks); for (const b of blocks) this.fetchForProvider(b, slug); } private tokensForTheme(slug: string): string[] { const tokens = this.themes.tokensFor(slug); // Special cases if (slug === 'trending') return []; if (slug === 'live') return ['live']; return tokens; } private labelFor(p: Provider): string { switch (p) { case 'youtube': return 'YouTube'; case 'twitch': return 'Twitch'; case 'peertube': return 'PeerTube'; case 'rumble': return 'Rumble'; case 'odysee': return 'Odysee'; case 'dailymotion': return 'Dailymotion'; } } private fetchForProvider(block: ProviderBlock, slug: string) { // Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo. if (slug === 'live' && !this.isLiveProvider(block.provider)) { block.loading = false; block.error = null; block.items = []; this.blocks.update(a => [...a]); return; } // Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls. const ready = this.instances.getProviderReadiness(block.provider); if (!ready.ready) { block.loading = false; block.error = ready.reason || 'Provider not ready'; console.warn('[ThemePage] Provider not ready', { provider: block.provider, theme: slug, reason: block.error }); this.blocks.update(a => [...a]); return; } // If YouTube quota already exceeded, surface and skip if (block.provider === 'youtube' && this.api.quotaExceeded$.getValue()) { block.loading = false; block.error = 'YouTube quota exceeded. Try again later or switch provider.'; console.warn('[ThemePage] Skip fetch: YouTube quota exceeded', { theme: slug }); this.blocks.update(a => [...a]); return; } const tokens = this.tokensForTheme(slug); const max = 10; block.loading = true; block.error = null; if (slug === 'trending') { this.api.getTrendingPage(null, block.provider).subscribe({ next: res => { block.items = (res.items || []).slice(0, max); block.loading = false; if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug }); this.blocks.update(a => [...a]); }, error: (e) => { if (block.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); block.error = 'YouTube quota exceeded. Try again later or switch provider.'; } else { block.error = 'Erreur de chargement'; } block.loading = false; this.blocks.update(a => [...a]); } }); return; } // Thème "En direct" : vrais lives uniquement. // - YouTube : search.list eventType=live (pas une recherche textuelle "live"). // - Twitch : top streams Helix (la recherche de chaînes "live" est trop restrictive). if (slug === 'live') { const liveCall = block.provider === 'youtube' ? this.api.searchYouTubeLivePage('', null) : this.api.getTwitchTopStreamsPage(null); const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams'; liveCall.subscribe({ next: res => { block.items = (res.items || []) .filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider })) .slice(0, max); block.loading = false; if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode }); this.blocks.update(a => [...a]); }, error: (e) => { if (block.provider === 'youtube' && e?.status === 403) { this.api.quotaExceeded$.next(true); block.error = 'YouTube quota exceeded. Try again later or switch provider.'; } else { block.error = 'Erreur de chargement'; } block.loading = false; this.blocks.update(a => [...a]); } }); return; } // Twitch hors live/trending : VODs + clips + fallback top streams. if (block.provider === 'twitch') { const q = tokens.join(' '); const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null); call.subscribe({ next: res => { block.items = (res.items || []).slice(0, max); block.loading = false; if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q }); this.blocks.update(a => [...a]); }, error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); } }); return; } const q = tokens.join(' '); if (!q) { // fallback to trending this.api.getTrendingPage(null, block.provider).subscribe({ next: res => { block.items = (res.items || []).slice(0, max); block.loading = false; if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' }); this.blocks.update(a => [...a]); }, error: (e) => { if (block.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); block.error = 'YouTube quota exceeded. Try again later or switch provider.'; } else { block.error = 'Erreur de chargement'; } block.loading = false; this.blocks.update(a => [...a]); } }); } else { this.api.searchVideosPage(q, null, block.provider).subscribe({ next: res => { block.items = (res.items || []).slice(0, max); block.loading = false; if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q }); this.blocks.update(a => [...a]); }, error: (e) => { if (block.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); block.error = 'YouTube quota exceeded. Try again later or switch provider.'; } else { block.error = 'Erreur de chargement'; } block.loading = false; this.blocks.update(a => [...a]); } }); } } watchQueryParams(provider: Provider, v: Video): Record { const qp: any = { p: provider }; if (provider === 'odysee' && v.url?.startsWith('https://odysee.com/')) { try { let slug = v.url.substring('https://odysee.com/'.length); if (slug.startsWith('/')) slug = slug.slice(1); qp.slug = slug; } catch {} } if (provider === 'twitch') { 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 ; un id VOD numérique ne doit jamais y passer. if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId; } } return qp; } }