Files
NewTube/src/components/search/search.component.ts
T

900 lines
34 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 { 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<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');
// 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;
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());
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
filterTag = signal<string>('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<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(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<string, any[]> = {};
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<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.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<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' }) {
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<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
}
// 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 <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.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
});
}
}