feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch
CI / build-and-test (push) Successful in 13m50s
CI / build-and-test (push) Successful in 13m50s
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
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';
|
||||
@@ -9,7 +9,9 @@ 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';
|
||||
@@ -20,9 +22,9 @@ import { Observable, Subscription } from 'rxjs';
|
||||
standalone: true,
|
||||
templateUrl: './search.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
|
||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent]
|
||||
})
|
||||
export class SearchComponent implements AfterViewInit {
|
||||
export class SearchComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(YoutubeApiService);
|
||||
@@ -34,6 +36,8 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
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);
|
||||
@@ -53,7 +57,6 @@ export class SearchComponent implements AfterViewInit {
|
||||
private lastRecordedKey: string | null = null;
|
||||
// Signature to detect new searches (q + providers + sort); excludes page
|
||||
private lastSearchSignature: string | null = null;
|
||||
private io?: IntersectionObserver;
|
||||
|
||||
hasQuery = computed(() => this.q().length > 0);
|
||||
providerLabel = computed(() => {
|
||||
@@ -65,14 +68,31 @@ export class SearchComponent implements AfterViewInit {
|
||||
return this.instances.selectedProviderLabel();
|
||||
});
|
||||
|
||||
// Active providers derived from `providers` query param or fallback to all available
|
||||
// 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 { return (this.instances.providers() || []).map(x => x.id as unknown as ProviderId); }
|
||||
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) return p as ProviderId[];
|
||||
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'];
|
||||
});
|
||||
|
||||
@@ -134,24 +154,9 @@ export class SearchComponent implements AfterViewInit {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set up IntersectionObserver for infinite scroll
|
||||
ngAfterViewInit(): void {
|
||||
const anchor = this.infiniteScrollAnchor?.nativeElement;
|
||||
if (!anchor) return;
|
||||
this.io = new IntersectionObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (e && e.isIntersecting) {
|
||||
this.loadNextPage();
|
||||
}
|
||||
}, { root: null, rootMargin: '200px 0px', threshold: 0 });
|
||||
this.io.observe(anchor);
|
||||
}
|
||||
|
||||
// Optional: clean up observer
|
||||
ngOnDestroy(): void {
|
||||
try { this.io?.disconnect(); } catch {}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -167,6 +172,13 @@ export class SearchComponent implements AfterViewInit {
|
||||
// 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();
|
||||
@@ -184,23 +196,24 @@ export class SearchComponent implements AfterViewInit {
|
||||
return Object.values(g).flat();
|
||||
});
|
||||
|
||||
// Helper computed: filtered list based on active tag
|
||||
// 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.applyTagFilter(list);
|
||||
const filtered = this.applyFilterBar(this.applyTagFilter(list));
|
||||
return this.sortVideos(filtered);
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const predicate = this.buildTagPredicate();
|
||||
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(predicate) : [];
|
||||
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
@@ -304,11 +317,16 @@ export class SearchComponent implements AfterViewInit {
|
||||
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.');
|
||||
}
|
||||
});
|
||||
@@ -344,12 +362,21 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
if (q) {
|
||||
this.notice.set(null);
|
||||
// Reset active tag on new query
|
||||
// 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)
|
||||
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
||||
// 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);
|
||||
|
||||
@@ -374,10 +401,16 @@ export class SearchComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
this.showUnified.set(true);
|
||||
this.loading.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 {
|
||||
@@ -482,13 +515,147 @@ export class SearchComponent implements AfterViewInit {
|
||||
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') {
|
||||
return [...list];
|
||||
// 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];
|
||||
@@ -500,6 +667,79 @@ export class SearchComponent implements AfterViewInit {
|
||||
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';
|
||||
@@ -563,7 +803,6 @@ export class SearchComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('infiniteScrollAnchor', { static: false }) infiniteScrollAnchor?: ElementRef<HTMLDivElement>;
|
||||
|
||||
// Legacy input handlers removed; SearchBox handles keyboard and submit
|
||||
|
||||
@@ -585,21 +824,18 @@ export class SearchComponent implements AfterViewInit {
|
||||
this.filterTag.set(key);
|
||||
}
|
||||
|
||||
// Pagination controls
|
||||
private loadNextPage() {
|
||||
const current = this.pageParam() || 1;
|
||||
const next = current + 1;
|
||||
if (this.loading() || this.endReached()) return;
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
page: next
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
// 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() {
|
||||
|
||||
Reference in New Issue
Block a user