CI / build-and-test (push) Successful in 14m8s
- 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
832 lines
33 KiB
TypeScript
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
|
|
});
|
|
}
|
|
}
|