feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch
CI / build-and-test (push) Successful in 13m50s

This commit is contained in:
2026-09-26 11:49:56 -04:00
parent 1b0a7eb3ce
commit 655061ba2c
46 changed files with 2018 additions and 380 deletions
@@ -30,12 +30,12 @@
<input #qInput type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
autocomplete="off" aria-label="Rechercher des vidéos"
role="combobox" aria-expanded="false" aria-autocomplete="list"
[attr.aria-expanded]="atOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
@@ -75,6 +75,31 @@
</ul>
</div>
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
class="absolute z-40 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden bg-slate-900 border border-slate-700 text-slate-200"
style="top: 100%">
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
class="max-h-72 overflow-y-auto py-1">
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
[attr.aria-selected]="i === suggestIndex()"
[id]="'sgOption-' + i">
<button type="button" (click)="applySuggestion(item.text, true)"
(mouseenter)="suggestIndex.set(i)"
class="w-full flex items-center gap-2 text-left px-3 py-2 text-sm"
[ngClass]="{
'bg-slate-800': i === suggestIndex(),
'hover:bg-slate-800': i !== suggestIndex()
}">
<span *ngIf="!item.provider" class="text-slate-500" title="Recherche récente">🕘</span>
<span *ngIf="item.provider" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
<span class="flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
</button>
</li>
</ul>
<div *ngIf="suggestLoading()" class="px-3 py-1 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
+90 -15
View File
@@ -42,15 +42,88 @@
}
@if (hasQuery()) {
<div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div class="flex items-center gap-2">
<label class="text-sm text-slate-300" for="sortSelect">Trier par</label>
<select id="sortSelect" class="bg-slate-900 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="sortParam()" (change)="onSortChange($event)">
<option value="relevance">Pertinence</option>
<option value="date">Date</option>
<option value="views">Vues</option>
</select>
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Filtrer par type de contenu">
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'all' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
Tout ({{ typeCounts().all }})
</button>
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'live' ? 'bg-purple-600 text-white border-purple-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
Directs ({{ typeCounts().live }})
</button>
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'video' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
Vidéos ({{ typeCounts().video }})
</button>
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
Clips ({{ typeCounts().clip }})
</button>
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
[ngClass]="typePill() === 'channel' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
Chaînes ({{ typeCounts().channel }})
</button>
</div>
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 my-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
<div>
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
[value]="sortParam()" (change)="onSortChange($event)">
<option value="relevance">{{ 'filter.sort.relevance' | t }}</option>
<option value="date">{{ 'filter.sort.date' | t }}</option>
<option value="views">{{ 'filter.sort.views' | t }}</option>
</select>
</div>
<div>
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
[value]="durationFilter()" (change)="onDurationChange($event)">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
[value]="typeFilter()" (change)="onTypeChange($event)">
<option value="any">{{ 'filter.type.any' | t }}</option>
<option value="video">{{ 'filter.type.video' | t }}</option>
<option value="live">{{ 'filter.type.live' | t }}</option>
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
<option value="short">{{ 'filter.type.short' | t }}</option>
<option value="channel">{{ 'filter.type.channel' | t }}</option>
</select>
</div>
<div>
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
[value]="languageFilter()" (change)="onLanguageChange($event)">
<option value="any">{{ 'filter.language.any' | t }}</option>
<option value="en">{{ 'filter.language.en' | t }}</option>
<option value="fr">{{ 'filter.language.fr' | t }}</option>
</select>
</div>
<div>
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
<select id="date-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
[value]="dateFilter()" (change)="onDateChange($event)">
<option value="any">{{ 'filter.date.any' | t }}</option>
<option value="day">{{ 'filter.date.day' | t }}</option>
<option value="week">{{ 'filter.date.week' | t }}</option>
<option value="month">{{ 'filter.date.month' | t }}</option>
<option value="year">{{ 'filter.date.year' | t }}</option>
</select>
</div>
</div>
</div>
@@ -61,17 +134,19 @@
</div>
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
<!-- Infinite scroll anchor & indicators -->
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
<div class="flex flex-col items-center justify-center py-6">
@if (loading()) {
@if (loading() || loadingMore()) {
<div class="text-slate-400 text-sm">Chargement…</div>
}
@if (endReached()) {
@if (!loading() && !loadingMore() && endReached() && filteredResults().length > 0) {
<div class="text-slate-500 text-xs">Fin des résultats</div>
}
</div>
<div #infiniteScrollAnchor class="h-1 w-full"></div>
<app-infinite-anchor [disabled]="endReached() || !hasQuery()"
[busy]="loading() || loadingMore()"
(loadMore)="loadNextPage()"></app-infinite-anchor>
}
</div>
+286 -50
View File
@@ -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() {