900 lines
34 KiB
TypeScript
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
|
|
});
|
|
}
|
|
}
|