Files
NewTube/src/components/search/search.component.ts
T
bruno 1814334958
CI / build-and-test (push) Successful in 14m8s
fix(search): loading par frame sur les cartes, plus de cadres vides
- video-card: etat de chargement de la miniature (shimmer + spinner),
  fallback 'Apertu indisponible' avec retry, referrerpolicy no-referrer
- grid: eager sur les premieres cartes, bandeau de rafraichissement quand
  la grille est deja peuplee
- search: garde MIN_QUERY_LENGTH=2 (le loading partait pour une requete que
  request$ filtrait, grille figee sur des skeletons) + filet de securite 20s
- skeleton: ligne chaine pour coller a la hauteur reelle de la carte
2026-09-28 19:35:48 -04:00

832 lines
33 KiB
TypeScript

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<string>('');
loading = signal<boolean>(false);
// Continuation (page > 1) load: grid stays mounted, only a bottom loader shows.
loadingMore = signal<boolean>(false);
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Step 7: default providers loaded from user preferences (server), applied when URL has none
private defaultProvidersPref = signal<ProviderId[] | null>(null);
pageParam = signal<number>(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<SearchFilters>({ ...DEFAULT_FILTERS });
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<string, any[]>>({});
showUnified = signal<boolean>(false);
error = signal<string | null>(null);
endReached = signal<boolean>(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<typeof setTimeout> | 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<ProviderId[]>(() => {
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<string, Provider> = {
'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<string, any[]>, incoming: Record<string, any[]>): Record<string, any[]> {
const result: Record<string, any[]> = { ...(prev || {}) };
const providers = new Set<string>([...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<string>();
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 <app-infinite-anchor> 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<any[]>(() => {
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<string, any[]> = {};
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<string, any[]> = {};
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<SearchFilters>);
// Reflets des <select> locaux.
if (key === 'duration') this.durationFilter.set(next.duration);
if (key === 'type') this.typeFilter.set(next.type);
if (key === 'period') this.dateFilter.set(next.period);
if (key === 'sort') this.sortParam.set(next.sort as 'relevance' | 'date' | 'views');
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
...filtersToQueryParams(next),
page: 1,
});
// Supprime les clés devenues 'all' (elles ne sont plus réinjectées).
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(next)[k]) delete (cleanParams as any)[k];
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true,
});
}
/** Retire un filtre depuis une pastille. */
clearFilter(key: keyof SearchFilters): void {
void this.setFilter(key, key === 'sort' ? 'relevance' : 'all');
}
clearAllFilters(): void {
const next = { ...DEFAULT_FILTERS };
this.filters.set(next);
this.durationFilter.set('all');
this.typeFilter.set('all');
this.dateFilter.set('all');
this.sortParam.set('relevance');
this.unified.setFilters(next);
const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: 1 });
for (const k of ['type', 'duration', 'period', 'sort']) delete (cleanParams as any)[k];
this.router.navigate([], {
relativeTo: this.route, queryParams: cleanParams, replaceUrl: true,
});
}
onDurationChange(event: Event) {
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onTypeChange(event: Event) {
void this.setFilter('type', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
onLanguageChange(event: Event) {
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
}
onDateChange(event: Event) {
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
}
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<string, any[]>();
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<string, unknown>)
.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'; filters?: SearchFilters }) {
const q = (evt?.q || '').trim();
if (!q) return;
// Les filtres actifs sont conservés : relancer une recherche ne doit pas
// perdre le périmètre choisi.
const filters = evt?.filters ? normalizeFilters(evt.filters) : this.filters();
const queryParams: any = {
...this.route.snapshot.queryParams,
q,
p: this.providerParam() || this.instances.selectedProvider(),
...filtersToQueryParams(filters),
page: 1, // reset pagination on new search
};
for (const k of ['type', 'duration', 'period', 'sort']) {
if (!filtersToQueryParams(filters)[k]) delete queryParams[k];
}
// 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<HTMLInputElement>;
// 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
}
// 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 <app-infinite-anchor> 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.armLoadingWatchdog();
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 -> même chemin que les autres filtres (URL + service)
onSortChange(event: Event) {
const value = (event.target as HTMLSelectElement)?.value as FilterSort | '';
void this.setFilter('sort', (value === 'date' || value === 'views') ? value : 'relevance');
}
// 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
});
}
}