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 { isShortVideo } from 'src/app/shared/utils/video-kind'; import { SearchService, type SearchResponse } from '../../app/search/search.service'; import { UserService, type UserPreferences } from '../../services/user.service'; import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry'; import { Observable, Subscription } from 'rxjs'; @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'); // 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; 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()); // Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'. // For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'. filterTag = signal('all'); // Filter bar (same UX as the provider/theme page): client-side selects. // Server-side sort stays on `sortParam` (URL); these four filter locally. durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any'); typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any'); languageFilter = signal<'any' | 'en' | 'fr'>('any'); dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any'); // 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(this.applyTagFilter(list)); return this.sortVideos(filtered); }); // Filter each provider group separately for sectioned rendering filteredGroups = computed(() => { const tagPredicate = this.buildTagPredicate(); 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) => tagPredicate(v) && barPredicate(v)) : []; }); return result; }); // Available tags computed from current results (only show tags that can apply) availableTags = computed(() => { const list = this.allVideos(); if (list.length === 0) return []; const tags: { key: string; label: string; show: boolean }[] = []; const now = Date.now(); // Non-Twitch providers: duration + date filters const oneDay = 24 * 60 * 60 * 1000; const sevenDays = 7 * oneDay; const thirtyDays = 30 * oneDay; const oneYear = 365 * oneDay; let hasShort = false, hasMedium = false, hasLong = false, hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false; for (const v of list) { const d = Number(v.durationSec || 0); const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0; const timeDiff = now - uploadTime; // Duration filters if (d > 0 && d < 4 * 60) hasShort = true; else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true; else if (d >= 20 * 60) hasLong = true; // Date filters if (uploadTime > 0) { if (timeDiff <= oneDay) hasToday = true; if (timeDiff <= sevenDays) hasThisWeek = true; if (timeDiff <= thirtyDays) hasThisMonth = true; if (timeDiff <= oneYear) hasThisYear = true; } } // Add duration filters tags.push({ key: 'all', label: 'Tout', show: list.length > 0 }); tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort }); tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium }); tags.push({ key: 'long', label: '20 min et plus', show: hasLong }); // Add date filters tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday }); tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek }); tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth }); tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear }); return tags.filter(t => t.show); }); 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.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.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); const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance'; this.sortParam.set(sortNorm); this.unified.setSort(sortNorm); if (q) { this.notice.set(null); // Reset active tag + filter bar on new query const provider = prov || this.instances.selectedProvider(); this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all'); this.durationFilter.set('any'); this.typeFilter.set('any'); this.languageFilter.set('any'); this.dateFilter.set('any'); // 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() || [provider 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) const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`; 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, 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); // 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.error.set(null); } else { this.loading.set(false); this.groups.set({} as any); this.showUnified.set(false); this.error.set(null); this.endReached.set(false); } }); } private durationSeconds(item: any): number { const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0; if (typeof raw === 'number') { return Number.isFinite(raw) ? raw : 0; } const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : 0; } private publishedTimestamp(item: any): number { const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null; if (typeof raw === 'number') { return Number.isFinite(raw) ? raw : 0; } if (typeof raw === 'string' && raw.trim()) { const parsed = Date.parse(raw); if (!Number.isNaN(parsed)) { return parsed; } } return 0; } 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; } private buildTagPredicate(): (item: any) => boolean { const tag = this.filterTag(); const provider = this.selectedProviderForView(); if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) { return () => true; } const now = Date.now(); const oneDay = 24 * 60 * 60 * 1000; const sevenDays = 7 * oneDay; const thirtyDays = 30 * oneDay; const oneYear = 365 * oneDay; switch (tag) { case 'short': return (item) => { const d = this.durationSeconds(item); return d > 0 && d < 4 * 60; }; case 'medium': return (item) => { const d = this.durationSeconds(item); return d >= 4 * 60 && d < 20 * 60; }; case 'long': return (item) => { const d = this.durationSeconds(item); return d >= 20 * 60; }; case 'today': return (item) => { const ts = this.publishedTimestamp(item); return ts > 0 && (now - ts) <= oneDay; }; case 'this_week': return (item) => { const ts = this.publishedTimestamp(item); return ts > 0 && (now - ts) <= sevenDays; }; case 'this_month': return (item) => { const ts = this.publishedTimestamp(item); return ts > 0 && (now - ts) <= thirtyDays; }; case 'this_year': return (item) => { const ts = this.publishedTimestamp(item); return ts > 0 && (now - ts) <= oneYear; }; default: return () => true; } } private applyTagFilter(list: any[]): any[] { const predicate = this.buildTagPredicate(); return Array.isArray(list) ? list.filter(predicate) : []; } // ---- Filter bar (same UX as the provider/theme page) ---- private isLiveItem(item: any): boolean { if (!item) return false; if (item.isLive === true) return true; // Strict: only an explicit 'live' type counts (unlike the shared util, // offline Twitch channels typed 'channel' must NOT match). return String(item.type || '').toLowerCase() === 'live'; } private isShortItem(item: any): boolean { if (!item) return false; return isShortVideo({ duration: this.durationSeconds(item), type: typeof item.type === 'string' ? item.type : undefined, provider: typeof item.provider === 'string' ? item.provider : undefined, }); } private isChannelItem(item: any): boolean { if (!item) return false; // Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct. if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true; return false; } private isClipItem(item: any): boolean { if (!item) return false; return String((item as any)?.kind || '').toLowerCase() === 'clip'; } /** 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' | 'short' | 'channel'>(() => { const t = this.typeFilter(); if (t === 'live') return 'live'; if (t === 'video' || t === 'recorded') return 'video'; if (t === 'short') return 'short'; if (t === 'channel') return 'channel'; return 'all'; }); setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void { if (v === 'all') this.typeFilter.set('any'); else if (v === 'video') this.typeFilter.set('video'); else this.typeFilter.set(v); } private buildFilterBarPredicate(): (item: any) => boolean { const duration = this.durationFilter(); const type = this.typeFilter(); const lang = this.languageFilter(); const date = this.dateFilter(); if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') { return () => true; } const now = Date.now(); const cutoff = date === 'any' ? 0 : { day: now - 24 * 3600 * 1000, week: now - 7 * 24 * 3600 * 1000, month: now - 30 * 24 * 3600 * 1000, year: now - 365 * 24 * 3600 * 1000, }[date] || 0; const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s); const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s); return (item: any) => { if (duration !== 'any') { const d = this.durationSeconds(item); if (duration === 'short' && !(d > 0 && d < 240)) return false; if (duration === 'medium' && !(d >= 240 && d < 1200)) return false; if (duration === 'long' && !(d >= 1200)) return false; } if (type !== 'any') { const live = this.isLiveItem(item); const short = this.isShortItem(item); const channel = this.isChannelItem(item); const clip = this.isClipItem(item); if (type === 'live' && !live) return false; if (type === 'channel' && !channel) return false; if (type === 'short' && !(short || clip) && !live && !channel) { // Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts. if (!clip) return false; } if (type === 'short' && (live || channel)) return false; // 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne). if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false; } if (lang !== 'any') { const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase(); if (lang === 'fr' && !isFrText(text)) return false; if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false; } if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false; return true; }; } private applyFilterBar(list: any[]): any[] { const predicate = this.buildFilterBarPredicate(); return Array.isArray(list) ? list.filter(predicate) : []; } onDurationChange(event: Event) { this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any'); } onTypeChange(event: Event) { this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any'); } onLanguageChange(event: Event) { this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any'); } onDateChange(event: Event) { this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any'); } private sortVideos(list: any[]): any[] { if (!Array.isArray(list)) { return []; } const mode = this.sortParam(); if (mode === 'relevance') { // Single merged list: round-robin interleave across providers (in the // user's provider order) instead of provider blocks. Prefix-stable // under infinite-scroll appends (per-provider order never changes). return this.interleaveByProvider(list); } const arr = [...list]; if (mode === 'date') { arr.sort((a, b) => this.publishedTimestamp(b) - this.publishedTimestamp(a)); } else if (mode === 'views') { arr.sort((a, b) => this.viewCount(b) - this.viewCount(a)); } return arr; } /** Normalize a provider token (short 'yt' or full 'youtube') to ProviderId. */ private toProviderId(v: unknown): ProviderId | null { const s = String(v || '').trim(); if (!s) return null; if ((PROVIDERS as { id: string }[]).some(p => p.id === s)) return s as ProviderId; const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s); return hit ? (hit[0] as ProviderId) : null; } /** Round-robin interleave preserving per-provider order and provider order. */ private interleaveByProvider(list: any[]): any[] { const order = (this.activeProviders() || []) .map(p => this.toProviderId(p)) .filter((p): p is ProviderId => !!p); const buckets = new Map(); const extras: any[] = []; for (const v of list) { const pid = this.toProviderId((v as any)?.provider); if (pid && order.includes(pid)) { const b = buckets.get(pid) || []; b.push(v); buckets.set(pid, b); } else { extras.push(v); } } const out: any[] = []; let idx = 0; let added = true; while (added) { added = false; for (const pid of order) { const b = buckets.get(pid); if (b && idx < b.length) { out.push(b[idx]); added = true; } } idx++; } return [...out, ...extras]; } private providerLabelOf(pid: string): string { try { return PROVIDERS.find(p => p.id === pid)?.displayName || pid; } catch { return pid; } } /** Show backend per-provider failures as an amber notice (partial results). */ private reflectProviderErrors(errors: unknown): void { if (!errors || typeof errors !== 'object') { this.notice.set(null); return; } const entries = Object.entries(errors as Record) .filter(([, m]) => typeof m === 'string' && String(m).trim().length > 0); if (entries.length === 0) { this.notice.set(null); return; } const parts = entries.map(([pid, m]) => { const msg = String(m).replace(/\s+/g, ' ').trim(); const short = msg.length > 140 ? msg.slice(0, 140) + '…' : msg; return `${this.providerLabelOf(pid)} : ${short}`; }); this.notice.set( `Résultats partiels — certains fournisseurs n'ont pas répondu (${parts.join(' · ')}).` ); } formatViews(views: number): string { if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B'; if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M'; if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K'; return views.toString(); } formatRelative(dateIso?: string): string { if (!dateIso) return ''; return formatRelativeFr(dateIso); } // Méthode utilitaire pour nettoyer les paramètres de l'URL private cleanUrlParams(params: any): any { const cleanParams = { ...params }; // Supprimer explicitement le paramètre theme if ('theme' in cleanParams) { delete cleanParams.theme; } // Nettoyer les valeurs null/undefined Object.keys(cleanParams).forEach(key => { if (cleanParams[key] === null || cleanParams[key] === undefined) { delete cleanParams[key]; } }); return cleanParams; } // Submit handler from the embedded SearchBox: keep URL as source of truth onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) { const q = (evt?.q || '').trim(); if (!q) return; // Créer un nouvel objet de paramètres de requête const queryParams: any = { q, p: this.providerParam() || this.instances.selectedProvider(), page: 1 // reset pagination on new search }; // Gérer les fournisseurs if (Array.isArray(evt.providers)) { queryParams.providers = evt.providers.join(','); } else if (evt.providers === 'all') { queryParams.providers = 'yt,dm,tw,pt,od,ru'; } // Nettoyer les paramètres avant la navigation const cleanParams = this.cleanUrlParams(queryParams); // Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique // et forcer le rechargement avec les nouveaux paramètres this.router.navigate([], { relativeTo: this.route, queryParams: cleanParams, queryParamsHandling: '', replaceUrl: true }); } @ViewChild('searchInput', { static: false }) searchInput?: ElementRef; // Legacy input handlers removed; SearchBox handles keyboard and submit closeSearchResults() { // Cette méthode sera appelée pour fermer les résultats de recherche // Vous pouvez ajouter une logique supplémentaire si nécessaire // Par exemple, si vous avez un état pour afficher/cacher les résultats // this.showResults.set(false); } onSearchInput(event: Event) { const input = event.target as HTMLInputElement; this.q.set(input.value); // Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire } // Update active filter tag from the template setFilterTag(key: string) { this.filterTag.set(key); } // Infinite-scroll pagination: append the next page WITHOUT touching the // router URL. Navigating (even with replaceUrl) rewrites the location hash // (HashLocationStrategy) and remounts list state, which throws the user // back to the top. The URL keeps page=1 as the canonical shareable state. // Public: bound from the shared sentinel. loadNextPage() { if (this.loading() || this.loadingMore() || this.endReached()) return; if (!this.q()) return; const next = (this.pageParam() || 1) + 1; this.pageParam.set(next); this.loadingMore.set(true); this.unified.setPage(next); } nextPage() { const next = (this.pageParam() || 1) + 1; const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: next }); this.router.navigate([], { relativeTo: this.route, queryParams: cleanParams, queryParamsHandling: '', replaceUrl: true }); } prevPage() { const prev = Math.max(1, (this.pageParam() || 1) - 1); const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: prev }); this.router.navigate([], { relativeTo: this.route, queryParams: cleanParams, queryParamsHandling: '', replaceUrl: true }); } // Sort control onSortChange(event: Event) { const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | ''; const sort = (value === 'date' || value === 'views') ? value : 'relevance'; const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, sort, page: 1 }); this.router.navigate([], { relativeTo: this.route, queryParams: cleanParams, queryParamsHandling: '', replaceUrl: true }); } // CTA handler: navigate to focus on a single provider while preserving q onViewAll(p: ProviderId) { const q = (this.q() || '').trim(); const qp: any = { provider: p, p, providers: p, page: 1 }; if (q) qp.q = q; const cleanParams = this.cleanUrlParams(qp); this.router.navigate([], { relativeTo: this.route, queryParams: cleanParams, queryParamsHandling: '', replaceUrl: true }); } }