import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, ParamMap } from '@angular/router'; import { YoutubeApiService } from '../../services/youtube-api.service'; import { formatRelativeFr } from '../../utils/date.util'; import { InstanceService, Provider } from '../../services/instance.service'; import { HistoryService } from '../../services/history.service'; import { Title } from '@angular/platform-browser'; import { TranslatePipe } from '../../pipes/translate.pipe'; import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component'; import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component'; import { VideoItem } from 'src/app/shared/models/video-item.model'; import { SearchService, type SearchResponse } from '../../app/search/search.service'; import { DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams, normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs, isLiveItem, isShortItem, isChannelItem, isClipItem, type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort, } from '../../app/search/filters'; import { UserService, type UserPreferences } from '../../services/user.service'; import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry'; import { Observable, Subscription } from 'rxjs'; /** Longueur minimale d'une requete (aligne sur `SearchService.request$` et le backend). */ const MIN_QUERY_LENGTH = 2; /** Filet de securite : le loading ne doit jamais rester bloque indefiniment. */ const LOADING_TIMEOUT_MS = 20_000; @Component({ selector: 'app-search', standalone: true, templateUrl: './search.component.html', changeDetection: ChangeDetectionStrategy.OnPush, imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent] }) export class SearchComponent { private route = inject(ActivatedRoute); private router = inject(Router); private api = inject(YoutubeApiService); private instances = inject(InstanceService); private history = inject(HistoryService); private title = inject(Title); private unified = inject(SearchService); private users = inject(UserService); q = signal(''); loading = signal(false); // Continuation (page > 1) load: grid stays mounted, only a bottom loader shows. loadingMore = signal(false); notice = signal(null); providerParam = signal(null); themeParam = signal(null); providersListParam = signal(null); // Step 7: default providers loaded from user preferences (server), applied when URL has none private defaultProvidersPref = signal(null); pageParam = signal(1); sortParam = signal<'relevance' | 'date' | 'views'>('relevance'); /** * Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`) * ET sont poussés dans `SearchService` : le serveur filtre vraiment ses * résultats (InnerTube/Data API quand c'est possible, post-filtrage sinon). * Les filtres locaux ci-dessous ne font plus qu'un dernier raffinement. */ filters = signal({ ...DEFAULT_FILTERS }); // Unified multi-provider response (grouped suggestions) groups = signal>({}); showUnified = signal(false); error = signal(null); endReached = signal(false); // Dedup key to avoid recording the same search multiple times private lastRecordedKey: string | null = null; // Signature to detect new searches (q + providers + sort); excludes page private lastSearchSignature: string | null = null; // Relache le loading si l'emetteur `request$` ne repond pas (filet de securite). private loadingWatchdog: ReturnType | null = null; hasQuery = computed(() => this.q().length > 0); providerLabel = computed(() => { const param = this.providerParam(); if (param) { const p = this.instances.providers().find(p => p.id === param); return p?.label || param; } return this.instances.selectedProviderLabel(); }); // Active providers derived from `providers` query param or fallback to all available. // Always normalized to short ids ('yt', …) so ordering/badges stay consistent. activeProviders = computed(() => { const norm = (v: unknown): ProviderId | null => { const s = String(v || '').trim(); if (!s) return null; if ((PROVIDERS as { id: string }[]).some(pr => pr.id === s)) return s as ProviderId; const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s); return hit ? (hit[0] as ProviderId) : null; }; const p = this.providersListParam(); if (p === 'all' || p == null) { try { const ids = (this.instances.providers() || []) .map(x => norm(x.id)) .filter((x): x is ProviderId => !!x); if (ids.length > 0) return ids; return ['yt','dm','tw','pt','od','ru']; } catch { return ['yt','dm','tw','pt','od','ru']; } } if (Array.isArray(p) && p.length > 0) { const ids = p.map(norm).filter((x): x is ProviderId => !!x); if (ids.length > 0) return ids; } return ['yt','dm','tw','pt','od','ru']; }); // Map between Provider and ProviderId private readonly providerIdMap: Record = { 'yt': 'youtube', 'dm': 'dailymotion', 'tw': 'twitch', 'pt': 'peertube', 'od': 'odysee', 'ru': 'rumble' }; // Get active providers with full info activeProvidersInfo = computed(() => { const activeIds = this.activeProviders(); return this.instances.providers().filter(p => activeIds.some(id => this.providerIdMap[id] === p.id) ); }); // Brand colors for provider badge (inline styles to match History badges) getProviderColors(providerId: string | null | undefined): { [key: string]: string } { const id = (providerId || '').toLowerCase(); switch (id) { case 'youtube': return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' }; case 'dailymotion': return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' }; case 'peertube': return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' }; case 'rumble': return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' }; case 'twitch': return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' }; case 'odysee': return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' }; default: return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' }; } } // Merge groups with dedup by id/videoId/url private mergeGroups(prev: Record, incoming: Record): Record { const result: Record = { ...(prev || {}) }; const providers = new Set([...Object.keys(prev || {}), ...Object.keys(incoming || {})]); providers.forEach(pid => { const a = Array.isArray(prev?.[pid]) ? prev[pid] : []; const b = Array.isArray(incoming?.[pid]) ? incoming[pid] : []; if (a.length === 0) { result[pid] = b.slice(); return; } if (b.length === 0) { result[pid] = a.slice(); return; } const seen = new Set(); const merged: any[] = []; const keyOf = (v: any) => String((v && (v.id || v.videoId || v.url)) ?? JSON.stringify(v)); a.forEach(v => { const k = keyOf(v); if (!seen.has(k)) { seen.add(k); merged.push(v); } }); b.forEach(v => { const k = keyOf(v); if (!seen.has(k)) { seen.add(k); merged.push(v); } }); result[pid] = merged; }); return result; } // Infinite scroll is driven by the shared sentinel // (same standard as home / provider-theme / channel grids): it emits // `loadMore` when visible, honoring `disabled` (end) and `busy` (loading). // Page title with search query pageHeading = computed(() => { const q = this.q(); if (q) { return `Résultats pour « ${q} »`; } return 'Recherche'; }); // Public computed used by the template to avoid referencing private `instances` selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider()); // Filter bar (same UX as the provider/theme page): the four selects write the // URL (server-side filtering) and are mirrored locally as a safety net for // providers that ignore a dimension. durationFilter = signal<'any' | FilterDuration>('any'); typeFilter = signal<'any' | FilterType>('any'); languageFilter = signal<'any' | 'en' | 'fr'>('any'); dateFilter = signal<'any' | FilterPeriod>('any'); /** Filtres actifs (pour les pastilles + l'indicateur « filtré »). */ activeFilterCount = computed(() => activeFilterCount(this.filters())); readonly filterGroupsView = computed(() => filterGroups(this.filters())); /** Étiquettes des filtres actifs, prêtes à afficher. */ activeFilterChips = computed(() => { const n = this.filters(); const out: { key: keyof SearchFilters; label: string }[] = []; for (const g of this.filterGroupsView()) { const k = g.key as keyof SearchFilters; if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue; const opt = g.options.find((o) => o.value === n[k]); if (opt) out.push({ key: k, label: opt.label }); } return out; }); // Unified total results count (sum of groups lengths) unifiedTotal = computed(() => { const g = this.groups(); try { return Object.values(g).reduce((acc: number, arr: any) => acc + (Array.isArray(arr) ? arr.length : 0), 0); } catch { return 0; } }); canPrev = computed(() => (this.pageParam() || 1) > 1); // Flatten all unified results into a single list of VideoItems allVideos = computed(() => { const g = this.groups(); if (!g) return []; return Object.values(g).flat(); }); // Helper computed: filtered list based on active tag + filter bar filteredResults = computed(() => { const list = this.allVideos(); if (!Array.isArray(list) || list.length === 0) return []; const filtered = this.applyFilterBar(list); return this.sortVideos(filtered); }); // Filter each provider group separately for sectioned rendering filteredGroups = computed(() => { const barPredicate = this.buildFilterBarPredicate(); const g = this.groups(); const result: Record = {}; Object.entries(g || {}).forEach(([pid, list]) => { result[pid] = Array.isArray(list) ? list.filter((v) => barPredicate(v)) : []; }); return result; }); constructor() { // Update document title when provider or query changes effect(() => { this.title.setTitle(this.pageHeading()); }); // Step 7: load default providers preference once (server) for deep-link fallback try { this.users.loadPreferences().subscribe({ next: (prefs: UserPreferences) => { const list = (prefs as any)?.defaultProviders; if (Array.isArray(list)) { const valid = list.filter((id: string) => PROVIDERS.some(p => p.id === id)) as ProviderId[]; if (valid.length > 0) this.defaultProvidersPref.set(valid); } }, error: () => {} // anonymous users: no preference }); } catch {} // Subscribe once to unified multi-provider results and reflect to UI const subscription: Subscription = this.unified.request$ .pipe(takeUntilDestroyed()) .subscribe({ next: (resp: any) => { // Convertir la réponse et concaténer si page > 1 (scroll infini) const incoming: Record = {}; if (resp.groups) { Object.entries(resp.groups).forEach(([key, value]) => { incoming[key] = Array.isArray(value) ? value : []; }); } const currentPage = this.pageParam() || 1; const isContinuation = currentPage > 1 && this.lastSearchSignature != null; if (isContinuation) { const merged = this.mergeGroups(this.groups(), incoming); this.groups.set(merged); // If no new items were added, mark end reached const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0); if (addedCount === 0) this.endReached.set(true); } else { this.groups.set(incoming); // If first page is empty for all providers, end reached const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0); this.endReached.set(total === 0); } this.loading.set(false); this.loadingMore.set(false); this.clearLoadingWatchdog(); this.error.set(null); // Surface per-provider backend failures (e.g. YouTube quota) so // users understand why only some providers return results. this.reflectProviderErrors(resp?.errors); }, error: () => { this.groups.set({} as any); this.loading.set(false); this.loadingMore.set(false); this.clearLoadingWatchdog(); this.error.set('Le service de recherche est temporairement indisponible. Réessayer.'); } }); // Listen to query param changes (so subsequent searches update) const paramSubscription: Subscription = this.route.queryParamMap .pipe(takeUntilDestroyed()) .subscribe((pm: ParamMap) => { const q = (pm.get('q') || '').trim(); this.q.set(q); const prov = (pm.get('provider') as Provider) || (pm.get('p') as Provider) || null; const theme = pm.get('theme'); const providersParam = (pm.get('providers') || '').trim(); const page = Number(pm.get('page') || '1'); const pageSize = Number(pm.get('pageSize') || '24'); const sort = pm.get('sort') || undefined; // Parse providers CSV if present let providersList: ProviderId[] | 'all' | null = null; if (providersParam) { const arr = providersParam.split(',').map(s => s.trim()).filter(Boolean) as ProviderId[]; providersList = (arr.length > 0) ? arr : 'all'; } this.providerParam.set(prov); this.themeParam.set(theme); this.providersListParam.set(providersList); const pageNum = isFinite(page) ? page : 1; this.pageParam.set(pageNum); this.unified.setPage(pageNum); if (isFinite(pageSize)) this.unified.setPageSize(pageSize); // Filtres (type / durée / période / tri) : lus dans l'URL puis poussés // dans le service -> la requête serveur change vraiment de périmètre. const filters = filtersFromQueryParams(pm); this.filters.set(filters); this.sortParam.set(filters.sort as 'relevance' | 'date' | 'views'); this.unified.setFilters(filters); // Reflets locaux de la barre de filtres (le `any` = pas de filtre local). this.durationFilter.set(filters.duration); this.typeFilter.set(filters.type); this.dateFilter.set(filters.period); if (q.length >= MIN_QUERY_LENGTH) { this.notice.set(null); // Reset the language filter on a new query (the other filters are URL // driven and must survive a query change). this.languageFilter.set('any'); const activeProvider = prov || this.instances.selectedProvider(); // Use unified search in all cases (URL providers > user default preference > single provider). // Normalized to short ids so every adapter resolves (legacy full names like // 'youtube' would otherwise match no adapter and return nothing). const rawProviders = providersList || this.defaultProvidersPref() || [activeProvider as ProviderId]; const providersToUse: ProviderId[] | 'all' = rawProviders === 'all' ? 'all' : (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p); this.unified.setQuery(q); this.unified.setProviders(providersToUse); // Determine if this is a new search (signature excludes page but // includes the filters: changing a filter restarts the results). const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${filters.sort}|${filters.type}.${filters.duration}.${filters.period}`; const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1; this.lastSearchSignature = signature; // Record this search once per unique (q, providers) combination try { const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`; if (this.lastRecordedKey !== key) { this.lastRecordedKey = key; const recFilters: any = { provider: activeProvider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' }; this.history.recordSearch(q, recFilters).subscribe({ next: () => {}, error: (err) => console.error('Error recording search:', err) }); } } catch (err) { console.error('Error in recordSearch:', err); } this.showUnified.set(true); if (isNewSearch) { this.groups.set({} as any); // Clear only for a new search this.endReached.set(false); this.loadingMore.set(false); this.loading.set(true); this.armLoadingWatchdog(); // New search (or sort/filter reset): results restart, scroll to top. // Continuation pages must NOT touch scroll (handled without router nav). try { window.scrollTo(0, 0); } catch {} } else if (!this.groups() || Object.keys(this.groups()).length === 0) { this.loading.set(true); this.armLoadingWatchdog(); } this.error.set(null); } else { this.loading.set(false); this.clearLoadingWatchdog(); this.groups.set({} as any); this.showUnified.set(false); this.error.set(null); this.endReached.set(false); } }); } /** * Arme le filet de securite du loading. Si `request$` ne repond pas (requete * supprimee par un `switchMap`, cache incoherent, etc.) on stoppe le loading au * lieu de laisser une grille figee sur des skeletons. */ private armLoadingWatchdog(): void { this.clearLoadingWatchdog(); this.loadingWatchdog = setTimeout(() => { this.loadingWatchdog = null; if (this.loading()) { this.loading.set(false); this.loadingMore.set(false); if (Object.keys(this.groups() || {}).length === 0) { this.error.set('La recherche met trop de temps a repondre. Reessayez.'); } } }, LOADING_TIMEOUT_MS); } private clearLoadingWatchdog(): void { if (this.loadingWatchdog != null) { clearTimeout(this.loadingWatchdog); this.loadingWatchdog = null; } } ngOnDestroy(): void { this.clearLoadingWatchdog(); } // Lectures de métadonnées déléguées au modèle de filtres partagé // (server/search-filters.mjs côté API, src/app/search/filters.ts côté front : // même implémentation = mêmes résultats). private durationSeconds(item: any): number { return itemDurationSec(item); } private publishedTimestamp(item: any): number { return itemPublishedTs(item); } private viewCount(item: any): number { const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0; if (typeof raw === 'number') { return Number.isFinite(raw) ? raw : 0; } const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : 0; } // ---- Filter bar (same UX as the provider/theme page) ---- // Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la // langue reste purement locale (aucun provider ne filtre par langue). private isLiveItem(item: any): boolean { return isLiveItem(item); } private isShortItem(item: any): boolean { return isShortItem(item); } private isChannelItem(item: any): boolean { return isChannelItem(item); } private isClipItem(item: any): boolean { return isClipItem(item); } /** Compteurs par type pour les pills façon directory Twitch. */ typeCounts = computed(() => { const list = this.allVideos(); let live = 0, video = 0, clip = 0, channel = 0; for (const v of list || []) { if (this.isChannelItem(v)) { channel++; continue; } if (this.isLiveItem(v)) { live++; continue; } if (this.isClipItem(v) || this.isShortItem(v)) { clip++; continue; } video++; } return { live, video, clip, channel, all: (list || []).length }; }); /** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */ typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => { const t = this.filters().type; if (t === 'live') return 'live'; if (t === 'video') return 'video'; if (t === 'shorts') return 'shorts'; if (t === 'channel') return 'channel'; return 'all'; }); setTypePill(v: 'all' | 'live' | 'video' | 'shorts' | 'channel'): void { void this.setFilter('type', v === 'all' ? 'all' : v); } private isFrText(s: string): boolean { return /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s); } private isEnText(s: string): boolean { return /\b(the|a|an|and|for)\b/i.test(s); } /** * Prédicat local : raffine les résultats *déjà filtrés par le serveur* avec * exactement les mêmes règles (durée / période / type) + la langue, qui n'est * pas supportée par les providers. */ private buildFilterBarPredicate(): (item: any) => boolean { const filters = this.filters(); const lang = this.languageFilter(); const hasServerFilter = activeFilterCount(filters) > 0; if (!hasServerFilter && lang === 'any') return () => true; return (item: any) => { if (hasServerFilter && !matchesFilters(item, filters)) return false; if (lang === 'any') return true; const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase(); if (lang === 'fr') return this.isFrText(text); return this.isEnText(text) && !this.isFrText(text); }; } private applyFilterBar(list: any[]): any[] { const predicate = this.buildFilterBarPredicate(); return Array.isArray(list) ? list.filter(predicate) : []; } /** * Écrit un filtre : l'URL devient la source de vérité (donc la requête * serveur repart avec le nouveau périmètre) et le signal local suit. */ setFilter(key: keyof SearchFilters, value: string): void { const next = normalizeFilters({ ...this.filters(), [key]: value } as Partial); // Reflets des