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
@@ -50,6 +50,29 @@
</div>
</div>
</div>
<!-- Langues à télécharger (sous-titres/transcripts) -->
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-700">
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.download_languages' | t : 'Langues à télécharger' }}</h3>
<p class="text-sm text-slate-400">{{ 'preferences.download_languages_hint' | t : 'Seules les langues activées sont affichées et téléchargées (sous-titres/transcriptions). Français et anglais par défaut.' }}</p>
</div>
<div class="p-5">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<label *ngFor="let opt of downloadLanguageOptions"
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 cursor-pointer hover:border-slate-500">
<input type="checkbox"
class="h-4 w-4 accent-red-600"
[checked]="isDownloadLanguageEnabled(opt.code)"
(change)="toggleDownloadLanguage(opt.code)"
[name]="'dl-' + opt.code" />
<span class="text-sm text-slate-200">{{ opt.label }}</span>
<span class="text-xs text-slate-500 uppercase">{{ opt.code }}</span>
</label>
</div>
<p *ngIf="downloadLanguages().length === 0" class="mt-3 text-sm text-red-300">{{ 'preferences.download_languages_empty' | t : 'Sélectionnez au moins une langue.' }}</p>
</div>
</div>
</form>
<!-- PeerTube Instances (below general) -->
@@ -35,6 +35,27 @@ export class PreferencesComponent {
themes = ['system', 'light', 'dark', 'black', 'blue'];
qualities = ['auto', '720p', '1080p'];
// Langues de sous-titres/transcripts à télécharger (défaut fr+en).
// Seules les langues activées sont affichées et téléchargées.
readonly DEFAULT_DOWNLOAD_LANGUAGES = ['fr', 'en'];
downloadLanguageOptions: Array<{ code: string; label: string }> = [
{ code: 'fr', label: 'Français' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'de', label: 'Deutsch' },
{ code: 'it', label: 'Italiano' },
{ code: 'pt', label: 'Português' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'pl', label: 'Polski' },
{ code: 'ru', label: 'Русский' },
{ code: 'ar', label: 'العربية' },
{ code: 'zh', label: '中文' },
{ code: 'ja', label: '日本語' },
{ code: 'hi', label: 'हिन्दी' },
{ code: 'ko', label: '한국어' },
];
downloadLanguages = signal<string[]>([...this.DEFAULT_DOWNLOAD_LANGUAGES]);
// PeerTube instances management
newPeerTubeInstance = signal<string>('');
peerTubeInstances = this.instances.peerTubeInstances; // signal<string[]>
@@ -51,6 +72,12 @@ export class PreferencesComponent {
// apply language immediately
if (p.language) this.i18n.setLanguage(p.language);
if (p.defaultProvider) this.defaultProvider.set(p.defaultProvider as Provider);
if (Array.isArray(p.downloadLanguages)) {
const cleaned = p.downloadLanguages
.map(v => String(v || '').trim().toLowerCase().split('-')[0])
.filter(v => this.downloadLanguageOptions.some(o => o.code === v));
this.downloadLanguages.set(Array.from(new Set(cleaned)));
}
if (p.theme) this.theme.set(p.theme);
if (p.videoQuality) this.videoQuality.set(p.videoQuality);
if (p.region) this.region.set(p.region);
@@ -61,14 +88,36 @@ export class PreferencesComponent {
} catch {}
}
isDownloadLanguageEnabled(code: string): boolean {
return this.downloadLanguages().includes(code);
}
toggleDownloadLanguage(code: string): void {
const current = [...this.downloadLanguages()];
const idx = current.indexOf(code);
if (idx >= 0) current.splice(idx, 1);
else current.push(code);
// Garde l'ordre du catalogue pour une persistance stable
const order = new Map(this.downloadLanguageOptions.map((o, i) => [o.code, i]));
current.sort((a, b) => (order.get(a) ?? 99) - (order.get(b) ?? 99));
this.downloadLanguages.set(current);
this.error.set(null);
}
async onSave(ev: Event) {
ev.preventDefault();
const langs = this.downloadLanguages();
if (!langs.length) {
this.error.set('Sélectionnez au moins une langue à télécharger.');
return;
}
this.saving.set(true);
this.notice.set(null);
try {
const patch: Partial<UserPreferences> = {
language: this.language(),
defaultProvider: this.defaultProvider(),
downloadLanguages: [...langs],
theme: this.theme(),
videoQuality: this.videoQuality(),
region: this.region(),
@@ -1,5 +1,5 @@
<!-- Loading initial : squelettes -->
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<!-- Loading initial : squelettes (uniquement si rien n'est encore affiché) -->
<div *ngIf="loading && videos.length === 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
</div>
@@ -13,19 +13,19 @@
</div>
<!-- Vide -->
<div *ngIf="!loading && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<div *ngIf="!loading && !loadingMore && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
<h3 class="text-lg font-semibold text-white">{{ emptyMessage }}</h3>
<p class="text-sm text-zinc-400">Essayez un autre onglet ou revenez plus tard.</p>
</div>
<!-- Grille vidéos classiques -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
<!-- Grille vidéos classiques (reste montée pendant le chargement suivant) -->
<div *ngIf="!error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<app-video-card *ngFor="let video of videos; trackBy: trackById" [video]="video"></app-video-card>
</div>
<!-- Grille shorts verticale 9:16 -->
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
<a *ngFor="let video of videos" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
<div *ngIf="!error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
<a *ngFor="let video of videos; trackBy: trackById" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
<div class="aspect-[9/16] w-full overflow-hidden bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
@@ -37,10 +37,14 @@
</a>
</div>
<!-- Sentinelle infinite scroll -->
<div *ngIf="hasMore && !loading && !error" #sentinel class="flex justify-center py-6" aria-hidden="true">
<div *ngIf="loadingMore" class="flex items-center gap-2 text-sm text-zinc-400">
<!-- Standard infinite scroll : sentinelle partagée + spinner + fin -->
<div *ngIf="loadingMore && hasMore && !error" class="flex justify-center py-6" aria-hidden="true">
<div class="flex items-center gap-2 text-sm text-zinc-400">
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
Chargement…
</div>
</div>
<div *ngIf="!loading && !loadingMore && !hasMore && !error && videos.length > 0" class="flex justify-center py-4 text-xs text-zinc-500">
Fin des résultats
</div>
<app-infinite-anchor *ngIf="hasMore && !loading && !error" [busy]="loadingMore" (loadMore)="loadMore.emit()"></app-infinite-anchor>
@@ -1,30 +1,25 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { VideoItem } from '../../../app/shared/models/video-item.model';
import { VideoCardComponent } from '../../../app/shared/components/video-card/video-card.component';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
import { InfiniteAnchorComponent } from '../../shared/infinite-anchor/infinite-anchor.component';
@Component({
selector: 'app-channel-video-grid',
standalone: true,
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent, InfiniteAnchorComponent],
templateUrl: './channel-video-grid.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
export class ChannelVideoGridComponent {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() loadingMore = false;
@@ -36,34 +31,7 @@ export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDe
@Output() loadMore = new EventEmitter<void>();
@Output() retry = new EventEmitter<void>();
@ViewChild('sentinel', { static: false }) sentinel?: ElementRef<HTMLDivElement>;
private observer: IntersectionObserver | null = null;
ngAfterViewInit(): void {
this.observe();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hasMore'] || changes['videos']) {
// Ré-observe après chaque rendu (sentinelle recréée par *ngIf)
queueMicrotask(() => this.observe());
}
}
ngOnDestroy(): void {
this.observer?.disconnect();
}
private observe(): void {
this.observer?.disconnect();
const el = this.sentinel?.nativeElement;
if (!el) return;
this.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) this.loadMore.emit();
},
{ rootMargin: '600px' },
);
this.observer.observe(el);
trackById(_index: number, v: VideoItem): string {
return String(v?.id ?? _index);
}
}
+13 -1
View File
@@ -79,7 +79,8 @@ export class HomeComponent {
return;
}
this.busyMore.set(true);
this.apiService.getTrendingPage(this.nextCursor()).subscribe(res => {
this.apiService.getTrendingPage(this.nextCursor()).subscribe({
next: res => {
const merged = [...this.trendingVideos(), ...res.items];
this.trendingVideos.set(merged);
this.nextCursor.set(res.nextCursor || null);
@@ -100,6 +101,17 @@ export class HomeComponent {
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
}
}
},
// Never leave the infinite scroll stuck: release flags so scrolling retries.
error: () => {
this.busyMore.set(false);
this.loading.set(false);
if (!this.notice()) {
this.notice.set(this.trendingVideos().length > 0
? 'Le chargement de la suite a échoué. Faites défiler pour réessayer.'
: 'Le chargement a échoué. Réessayez plus tard ou changez de fournisseur.');
}
}
});
}
@@ -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() {
@@ -24,8 +24,8 @@
</div>
</div>
<!-- Filtre rapide Vidéos / Shorts -->
<div class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
@@ -43,6 +43,43 @@
</button>
</div>
<!-- Onglets Twitch façon directory : Directs / Vidéos / Clips / Chaînes -->
<div *ngIf="isTwitchSections()" class="mb-4">
<div *ngIf="twitchGame()" class="mb-3 flex items-center gap-3 rounded-xl border border-purple-500/30 bg-purple-950/30 px-4 py-2.5">
<span class="text-2xl">🎮</span>
<div>
<div class="text-sm text-purple-300">Catégorie Twitch</div>
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
</div>
</div>
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Tout
</button>
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
</button>
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
</button>
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
</button>
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
</button>
</div>
</div>
<!-- Filters -->
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
@@ -66,29 +103,30 @@
<div>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" 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">
<option value="any">Any</option>
<option value="video">Video</option>
<option value="live">Live</option>
<option value="recorded">Recorded</option>
<option value="short">Shorts</option>
<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" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" 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">
<option value="any">Any</option>
<option value="en">English</option>
<option value="fr">Français</option>
<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" [ngModel]="date()" (ngModelChange)="onDateChange($event)" 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">
<option value="any">Any</option>
<option value="day">Last 24h</option>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="year">Last year</option>
<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>
@@ -105,20 +143,22 @@
</div>
</div>
<div *ngIf="!loading() && videos().length === 0" class="text-center py-16">
<div *ngIf="!loading() && !error() && videos().length === 0" class="text-center py-16">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-lg font-medium text-slate-300">{{ 'empty.noItems' | t }}</h3>
<p class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
<p *ngIf="theme() === 'live'" class="mt-1 text-sm text-slate-400">Aucun direct pour le moment — réessayez plus tard.</p>
<p *ngIf="theme() !== 'live'" class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
</div>
<div *ngIf="error()" class="mt-4 p-4 bg-red-900/30 border border-red-700 text-red-200 rounded-lg text-center">
<p>{{ error() }}</p>
<button class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
<button *ngIf="error() !== 'Ce fournisseur ne propose pas de directs.'" class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<!-- Grille unique (tous providers sauf vue "Tout" Twitch) -->
<div *ngIf="!showTwitchSectionsAll()" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
<div class="relative">
@@ -127,13 +167,18 @@
<app-provider-badge [provider]="provider()"></app-provider-badge>
</div>
<div class="absolute top-2 left-2 flex gap-1">
<span *ngIf="kindOf(v) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="kindOf(v) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
<span *ngIf="!isLiveCard(v) && kindOf(v) === 'short' && !isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
</div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
{{ v.duration | duration }}
</div>
<div class="absolute top-2 left-2">
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">
{{ v.views | number }} spectateurs
</div>
<div *ngIf="!isLiveCard(v)" class="absolute bottom-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
@@ -150,22 +195,120 @@
[name]="v.uploaderName || null"
></app-channel-identity>
<div class="mt-1.5 text-xs text-slate-400">
<span>{{ v.views | number }} visionnements</span> &bull; <span>{{ v.uploadedDate | date:'shortDate' }}</span>
<span *ngIf="isChannelCard(v)">Chaîne Twitch</span>
<span *ngIf="!isChannelCard(v)">{{ v.views | number }} visionnements</span>
<span *ngIf="!isChannelCard(v) && v.uploadedDate"> &bull; {{ v.uploadedDate | date:'shortDate' }}</span>
</div>
</div>
</div>
</div>
<div *ngIf="loading() && videos().length > 0" class="text-center py-8">
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<!-- Vue "Tout" Twitch : sections empilées façon twitch.tv/directory -->
<div *ngIf="showTwitchSectionsAll()" class="space-y-10">
<section *ngIf="twitchLives().length > 0">
<div class="flex items-center gap-2 mb-3">
<span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
<h3 class="text-xl font-semibold text-slate-100">En direct</h3>
<span class="text-sm text-slate-400">({{ twitchLives().length }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of twitchLives(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div class="absolute top-2 left-2"><span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span></div>
<div *ngIf="v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">{{ v.views | number }} spectateurs</div>
</div>
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
</a>
<div class="px-3 pb-3">
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
</div>
</div>
</div>
</section>
<section *ngIf="twitchVods().length > 0">
<div class="flex items-center gap-2 mb-3">
<h3 class="text-xl font-semibold text-slate-100">Vidéos</h3>
<span class="text-sm text-slate-400">({{ twitchVods().length }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of twitchVods(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
</div>
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
</a>
<div class="px-3 pb-3">
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> &bull; {{ v.uploadedDate | date:'shortDate' }}</span></div>
</div>
</div>
</div>
</section>
<section *ngIf="twitchClips().length > 0">
<div class="flex items-center gap-2 mb-3">
<span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
<h3 class="text-xl font-semibold text-slate-100">Clips</h3>
<span class="text-sm text-slate-400">({{ twitchClips().length }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of twitchClips(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div class="absolute top-2 left-2"><span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span></div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
</div>
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
</a>
<div class="px-3 pb-3">
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> &bull; {{ v.uploadedDate | date:'shortDate' }}</span></div>
</div>
</div>
</div>
</section>
<section *ngIf="twitchChannels().length > 0">
<div class="flex items-center gap-2 mb-3">
<h3 class="text-xl font-semibold text-slate-100">Chaînes</h3>
<span class="text-sm text-slate-400">({{ twitchChannels().length }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of twitchChannels(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
<div class="relative flex items-center justify-center bg-slate-900 py-6">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="h-24 w-24 rounded-full object-cover ring-2 ring-purple-500/50">
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div class="absolute top-2 left-2"><span class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span></div>
</div>
<div class="p-3 pb-1 text-center"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2">{{ v.title }}</h3></div>
</a>
<div class="px-3 pb-3">
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
<div class="mt-1.5 text-xs text-slate-400 text-center">Chaîne Twitch</div>
</div>
</div>
</div>
</section>
</div>
<div *ngIf="nextCursor()" class="text-center mt-8">
<button (click)="loadMore()" [disabled]="loading()" class="px-6 py-3 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 text-slate-200 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all">
{{ 'loading.more' | t }}
</button>
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
<div class="flex flex-col items-center justify-center py-6">
<div *ngIf="loadingMore()" class="text-center">
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<div *ngIf="!loading() && !loadingMore() && !nextCursor() && !error() && videos().length > 0" class="text-slate-500 text-xs">Fin des résultats</div>
</div>
<app-infinite-anchor [disabled]="!nextCursor()" [busy]="loading() || loadingMore()" (loadMore)="loadMore()"></app-infinite-anchor>
</div>
@@ -11,8 +11,9 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, Observable } from 'rxjs';
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
import { PagedResult } from '../../services/youtube-api.service';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
@@ -47,7 +48,7 @@ const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent]
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
@@ -66,20 +67,85 @@ export class ProviderThemePageComponent implements OnDestroy {
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// Data state
allVideos = signal<Video[]>([]);
nextCursor = signal<string | null>(null);
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
/** Catégorie Twitch résolue (directory/category façon twitch.tv). */
twitchGame = signal<{ id: string; name: string; boxArtUrl?: string } | null>(null);
loading = signal(false);
// Continuation (cursor > first page): grid stays mounted, bottom loader only.
loadingMore = signal(false);
error = signal<string | null>(null);
// Derived state for the UI
providersList = computed(() => this.instances.providers());
videos = computed(() => this.applyFilters(this.allVideos()));
/** La page courante est-elle une page Twitch à sections (hors thème live) ? */
isTwitchSections = computed(() => this.provider() === 'twitch' && this.theme() !== 'live');
/** Listes Twitch par section (filtres hors-type appliqués), façon directory. */
private twitchFilteredNoType(): Video[] {
return this.applyFiltersWithType(this.allVideos(), 'any');
}
twitchLives = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v =>
isLiveVideo({ ...v, provider: (v.provider as string) || p }));
});
twitchVods = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
const w = { ...v, provider: (v.provider as string) || p };
if (isLiveVideo(w)) return false;
if (isShortVideo(w)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
return true;
});
});
twitchClips = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
const w = { ...v, provider: (v.provider as string) || p };
if (isLiveVideo(w)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo(w);
});
});
twitchChannels = computed(() => {
const p = this.provider();
return this.twitchFilteredNoType().filter(v => {
if ((v as any).isLive === true) return false;
if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false;
return String(v.type || '').toLowerCase() === 'channel';
});
});
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
const t = this.type();
if (t === 'live') return 'live';
if (t === 'video' || t === 'recorded') return 'video';
if (t === 'short') return 'clip';
if (t === 'channel') return 'channel';
return 'all';
});
setTwitchTab(v: 'all' | 'live' | 'video' | 'clip' | 'channel'): void {
if (v === 'all') this.type.set('any');
else if (v === 'video') this.type.set('video');
else if (v === 'clip') this.type.set('short');
else this.type.set(v);
}
constructor() {
this.route.paramMap.subscribe(pm => {
const provider = (pm.get('provider') as Provider) || 'youtube';
@@ -87,6 +153,16 @@ export class ProviderThemePageComponent implements OnDestroy {
this.provider.set(provider);
this.theme.set(theme);
this.instances.setSelectedProvider(provider);
// Thème "En direct" : activer le filtre "Live" par défaut et lever les
// filtres qui masqueraient les lives (durée/date = any).
if (theme === 'live') {
this.type.set('live');
this.duration.set('any');
this.date.set('any');
} else if (this.type() === 'live') {
// En quittant le thème live, ne pas rester bloqué sur le filtre Live.
this.type.set('any');
}
this.resetAndLoad();
});
@@ -105,7 +181,10 @@ export class ProviderThemePageComponent implements OnDestroy {
this.version++;
this.allVideos.set([]);
this.nextCursor.set(null);
this.twitchCursors = { live: null, vod: null, clip: null, channel: null };
this.twitchGame.set(null);
this.error.set(null);
this.loadingMore.set(false);
this.loadMore();
}
@@ -127,7 +206,7 @@ export class ProviderThemePageComponent implements OnDestroy {
}
loadMore() {
if (this.loading()) return;
if (this.loading() || this.loadingMore()) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
@@ -135,7 +214,10 @@ export class ProviderThemePageComponent implements OnDestroy {
return;
}
this.loading.set(true);
// First page (empty list) -> full loader; continuations -> bottom loader,
// grid stays mounted so scroll position is preserved.
if (this.allVideos().length > 0) this.loadingMore.set(true);
else this.loading.set(true);
this.error.set(null);
const snapshotVersion = this.version;
const provider = this.provider();
@@ -144,36 +226,28 @@ export class ProviderThemePageComponent implements OnDestroy {
const themeSlug = this.theme();
const isLiveTheme = themeSlug === 'live';
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) {
this.error.set('Ce fournisseur ne propose pas de directs.');
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
return;
}
let apiCall: Observable<PagedResult<Video>>;
if (provider === 'twitch') {
if (isLiveTheme) {
// Thème live : top streams (jamais vide quand Twitch est configuré).
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
} else {
// Thème classique : VODs puis clips, puis top streams en filet de sécurité.
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
switchMap((res) => {
if ((res?.items?.length || 0) >= 8) {
return of(res);
}
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })),
switchMap((merged: any) => {
if (((merged?.items?.length) || 0) >= 4) return of(merged);
return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe(
map((top) => {
const seen = new Set((merged?.items || []).map((v: Video) => v.videoId));
const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId));
return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null };
}),
// En cas d'échec du fallback, garder au moins VODs+clips.
catchError(() => of(merged))
);
})
);
})
);
}
if (isLiveTheme && provider === 'youtube') {
// Directs YouTube : search.list eventType=live (vrais lives uniquement).
apiCall = this.api.searchYouTubeLivePage('', cursor || undefined);
} else if (provider === 'twitch' && !isLiveTheme) {
// Page Twitch à sections façon directory : lives / vidéos / clips / chaînes
// chargés en parallèle avec leurs propres curseurs Helix.
this.loadTwitchSections(snapshotVersion, provider, query);
return;
} else if (provider === 'twitch') {
// Thème live : top streams (jamais vide quand Twitch est configuré).
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
} else {
apiCall = query
? this.api.searchVideosPage(query, cursor, provider)
@@ -184,27 +258,127 @@ export class ProviderThemePageComponent implements OnDestroy {
next: (res: PagedResult<Video>) => {
if (snapshotVersion !== this.version) return;
// Thème live : garde-fou, ne jamais mélanger des VODs.
let items = res.items || [];
if (isLiveTheme) {
items = items.filter((v: Video) =>
isLiveVideo({ ...v, provider: (v.provider as string) || provider })
);
}
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
const newVideos = items.filter((v: Video) => !existingIds.has(v.videoId));
this.allVideos.update(current => [...current, ...newVideos]);
this.nextCursor.set(res.nextCursor || null);
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
},
error: (e) => {
if (snapshotVersion !== this.version) return;
this.error.set(e.message || 'Failed to load videos');
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
}
});
}
private applyFilters(list: Video[]): Video[] {
return this.applyFiltersWithType(list, this.type());
}
/**
* Chargement Twitch à sections (directory/category) : lives (+ catégorie),
* VODs, clips et chaînes en parallèle, chacun avec son curseur Helix.
* En continuation on ne recharge que l'onglet actif (ou tout si "Tout").
*/
private loadTwitchSections(snapshotVersion: number, provider: Provider, query: string): void {
const firstPage = this.allVideos().length === 0;
const tab = this.twitchTab();
const wantLive = firstPage || tab === 'all' || tab === 'live';
const wantVod = firstPage || tab === 'all' || tab === 'video';
const wantClip = firstPage || tab === 'all' || tab === 'clip';
const wantChannel = firstPage || tab === 'all' || tab === 'channel';
const finish = (items: Video[], next: string | null, err?: string) => {
if (snapshotVersion !== this.version) return;
if (err) this.error.set(err);
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
const fresh = items.filter(v => !existing.has(`${v.type}:${v.videoId}`));
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
this.nextCursor.set(next);
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
};
const fetchAll = (gameId: string | null) => {
const calls: Record<string, Observable<PagedResult<Video>>> = {};
if (wantLive) {
calls['live'] = gameId
? this.api.getTwitchGameStreamsPage(gameId, this.twitchCursors.live || undefined, 12).pipe(
catchError(() => this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined)))
: this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined);
}
if (wantVod) calls['vod'] = this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined);
if (wantClip) calls['clip'] = this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined);
if (wantChannel) calls['channel'] = this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20);
if (Object.keys(calls).length === 0) {
this.loading.set(false);
this.loadingMore.set(false);
this.cdr.markForCheck();
return;
}
forkJoin(calls).subscribe({
next: (res) => {
if (snapshotVersion !== this.version) return;
const r = res as Record<string, PagedResult<Video>>;
if (r['live']) this.twitchCursors.live = r['live'].nextCursor || null;
if (r['vod']) this.twitchCursors.vod = r['vod'].nextCursor || null;
if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null;
if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null;
const items = Object.values(r).flatMap(x => x?.items || []);
// Filet de sécurité : si VODs+clips vides et aucun live, compléter par top streams.
const hasContent = items.length > 0;
if (!hasContent && firstPage) {
this.api.getTwitchTopStreamsPage(null).subscribe({
next: (top) => finish([...items, ...(top.items || [])], top.nextCursor || null),
error: () => finish(items, null),
});
return;
}
const next = this.twitchCursors.live || this.twitchCursors.vod || this.twitchCursors.clip || this.twitchCursors.channel;
finish(items, next);
},
error: (e) => finish([], null, e?.message || 'Failed to load videos'),
});
};
if (firstPage) {
// Première page : résoudre la catégorie Twitch pour des lives thématiques.
this.api.searchTwitchCategory(query).subscribe({
next: (game) => {
if (snapshotVersion !== this.version) return;
if (game) this.twitchGame.set(game);
fetchAll(game?.id || null);
},
error: () => {
if (snapshotVersion !== this.version) return;
fetchAll(null);
},
});
} else {
fetchAll(this.twitchGame()?.id || null);
}
}
private applyFiltersWithType(list: Video[], typeOverride: 'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'): Video[] {
let arr = [...list];
const isLiveTheme = this.theme() === 'live';
const durationFilter = this.duration();
if (durationFilter !== 'any') {
// Les lives n'ont pas de durée connue (0) : le filtre durée ne s'applique pas sur le thème live.
if (durationFilter !== 'any' && !isLiveTheme) {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
@@ -214,18 +388,33 @@ export class ProviderThemePageComponent implements OnDestroy {
});
}
const typeFilter = this.type();
const typeFilter = typeOverride;
if (typeFilter !== 'any') {
const p = this.provider();
arr = arr.filter(v => {
const withProvider = { ...v, provider: (v.provider as string) || p };
if (typeFilter === 'video') return !isShortVideo(withProvider) && (v.type === 'video' || v.type === 'recorded');
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel' || v.type === 'stream' || (v as any).isLive === true;
if (typeFilter === 'recorded') return v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider));
// Shorts : classification partagée (durée 0/absente ⇒ jamais un Short).
if (typeFilter === 'short') return isShortVideo(withProvider);
if (typeFilter === 'video') return !isShortVideo(withProvider) && !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'video' || v.type === 'recorded');
if (typeFilter === 'live') return isLiveVideo(withProvider);
if (typeFilter === 'recorded') return !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider)));
// Shorts / Clips : classification partagée + kind='clip' (durée parfois absente).
if (typeFilter === 'short') {
if (isLiveVideo(withProvider)) return false;
if (String(v.type || '').toLowerCase() === 'channel') return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo(withProvider);
}
// Chaînes : hors-ligne uniquement (les lives restent dans "Direct").
if (typeFilter === 'channel') {
if ((v as any).isLive === true) return false;
if (isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel') return false;
return String(v.type || '').toLowerCase() === 'channel';
}
return true;
});
} else if (this.theme() === 'live') {
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
const p = this.provider();
arr = arr.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || p }));
}
const langFilter = this.language();
@@ -289,6 +478,31 @@ export class ProviderThemePageComponent implements OnDestroy {
return classifyVideo({ ...v, provider: (v.provider as string) || p });
}
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
isLiveCard(v: Video): boolean {
if ((v as any).isLive === true) return true;
if (String(v.type || '').toLowerCase() === 'live') return true;
if (String(v.type || '').toLowerCase() === 'channel') return false;
const p = this.provider();
return isLiveVideo({ ...v, provider: (v.provider as string) || p })
&& String(v.type || '').toLowerCase() !== 'channel';
}
isChannelCard(v: Video): boolean {
if ((v as any).isLive === true) return false;
return String(v.type || '').toLowerCase() === 'channel';
}
isClipCard(v: Video): boolean {
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
const p = this.provider();
return isShortVideo({ ...v, provider: (v.provider as string) || p });
}
/** Affichage "Tout" Twitch : sections empilées façon directory. */
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
shortsCount(): number {
const p = this.provider();
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
@@ -309,11 +523,21 @@ export class ProviderThemePageComponent implements OnDestroy {
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
if (p === 'twitch') {
// Clips : embed dédié ?clip=<slug> (jamais ?channel=, le slug n'est pas un login).
if (String((v as any).kind || '').toLowerCase() === 'clip') {
qp.clip = v.videoId;
return qp;
}
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
// ?channel= exige le login (non numérique) ; un id numérique = VOD.
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
}
}
return qp;
}
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
trackByVideo(_idx: number, v: Video) { return `${v.type}:${v.videoId}` || v.url; }
ngOnDestroy(): void {
this.version++; // Invalidate any pending requests
@@ -36,7 +36,8 @@
<!-- Error / empty -->
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !isLiveProvider(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !isLiveProvider(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
</div>
@@ -55,13 +56,18 @@
<app-provider-badge [provider]="b.provider"></app-provider-badge>
</div>
<div class="absolute top-2 left-2 flex gap-1">
<span *ngIf="kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="kindOf(v, b.provider) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
<span *ngIf="isLiveCard(v, b.provider)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
<span *ngIf="!isLiveCard(v, b.provider) && isClipCard(v, b.provider)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
<span *ngIf="!isLiveCard(v, b.provider) && !isClipCard(v, b.provider) && kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
</div>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
{{ v.duration | duration }}
</div>
<div class="absolute bottom-2 left-2">
<div *ngIf="isLiveCard(v, b.provider) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
{{ v.views | number }} spectateurs
</div>
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v, b.provider)">
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
+79 -14
View File
@@ -10,7 +10,7 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
interface ProviderBlock {
provider: Provider;
@@ -48,11 +48,27 @@ export class ThemePageComponent {
this.contentFilter.set(v);
}
/** Items du bloc après application du filtre Vidéos/Shorts. */
/** Fournisseurs capables de fournir de vrais directs. */
private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
isLiveTheme(): boolean {
return this.themeSlug() === 'live';
}
isLiveProvider(p: Provider): boolean {
return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p);
}
/** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
filteredItems(b: ProviderBlock): Video[] {
let items = b.items;
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
if (this.themeSlug() === 'live') {
items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider }));
}
const f = this.contentFilter();
if (f === 'all') return b.items;
return b.items.filter(v =>
if (f === 'all') return items;
return items.filter(v =>
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
);
}
@@ -61,6 +77,26 @@ export class ThemePageComponent {
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
}
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
isLiveCard(v: Video, provider: Provider): boolean {
if ((v as any).isLive === true) return true;
if (String(v.type || '').toLowerCase() === 'live') return true;
if (String(v.type || '').toLowerCase() === 'channel') return false;
return isLiveVideo({ ...v, provider: (v.provider as string) || provider })
&& String(v.type || '').toLowerCase() !== 'channel';
}
isChannelCard(v: Video): boolean {
if ((v as any).isLive === true) return false;
return String(v.type || '').toLowerCase() === 'channel';
}
isClipCard(v: Video, provider: Provider): boolean {
if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false;
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
return isShortVideo({ ...v, provider: (v.provider as string) || provider });
}
shortsCount(b: ProviderBlock): number {
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
}
@@ -100,6 +136,14 @@ export class ThemePageComponent {
}
private fetchForProvider(block: ProviderBlock, slug: string) {
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
if (slug === 'live' && !this.isLiveProvider(block.provider)) {
block.loading = false;
block.error = null;
block.items = [];
this.blocks.update(a => [...a]);
return;
}
// Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls.
const ready = this.instances.getProviderReadiness(block.provider);
if (!ready.ready) {
@@ -141,17 +185,32 @@ export class ThemePageComponent {
});
return;
}
// Twitch / live : les top streams live garantissent un bloc rempli
// (la recherche de chaînes "live" est trop restrictive).
if (block.provider === 'twitch' && slug === 'live') {
this.api.getTwitchTopStreamsPage(null).subscribe({
// Thème "En direct" : vrais lives uniquement.
// - YouTube : search.list eventType=live (pas une recherche textuelle "live").
// - Twitch : top streams Helix (la recherche de chaînes "live" est trop restrictive).
if (slug === 'live') {
const liveCall = block.provider === 'youtube'
? this.api.searchYouTubeLivePage('', null)
: this.api.getTwitchTopStreamsPage(null);
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
liveCall.subscribe({
next: res => {
block.items = (res.items || []).slice(0, max);
block.items = (res.items || [])
.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider }))
.slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'twitch_top_streams' });
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
this.blocks.update(a => [...a]);
},
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403) {
this.api.quotaExceeded$.next(true);
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else {
block.error = 'Erreur de chargement';
}
block.loading = false; this.blocks.update(a => [...a]);
}
});
return;
}
@@ -220,9 +279,15 @@ export class ThemePageComponent {
qp.slug = slug;
} catch {}
}
if (provider === 'twitch' && v.type === 'channel') {
// Try to use channel name
if (v.videoId) qp.channel = v.videoId;
if (provider === 'twitch') {
if (String((v as any).kind || '').toLowerCase() === 'clip') {
qp.clip = v.videoId;
return qp;
}
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
// ?channel= exige le login ; un id VOD numérique ne doit jamais y passer.
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
}
}
return qp;
}
+61 -10
View File
@@ -992,6 +992,32 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// quelle langue" : on ne réessaie jamais deux fois la même).
private transcriptTriedLangs = new Set<string>();
/** Langues de transcripts autorisées par les préférences (défaut fr,en). */
private allowedTranscriptLanguages(): string[] {
try {
const prefs = this.users.preferences();
const raw = (prefs as { downloadLanguages?: unknown } | null)?.downloadLanguages;
if (Array.isArray(raw) && raw.length) {
const cleaned = raw
.map(v => String(v || '').trim().toLowerCase().split('-')[0])
.filter(v => /^[a-z]{2,3}$/.test(v));
const deduped = Array.from(new Set(cleaned));
if (deduped.length) return deduped;
}
if (Array.isArray(raw) && raw.length === 0) return [];
} catch {}
return ['fr', 'en'];
}
/** Ne jamais afficher une langue non activée dans les préférences. */
private filterTranscriptLanguages(languages: unknown): string[] {
const allowed = new Set(this.allowedTranscriptLanguages().map(l => l.toLowerCase()));
if (!Array.isArray(languages)) return [];
return (languages as unknown[])
.map(l => String(l || '').trim().slice(0, 12))
.filter(l => !!l && allowed.has(l.toLowerCase().split('-')[0]));
}
// --- Transcript logic (Phase 2) ---
private resetTranscriptUi() {
this.transcriptOpen.set(false);
@@ -1013,8 +1039,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.stopTranscriptClock();
this.transcriptTriedLangs.clear();
try {
const allowed = this.allowedTranscriptLanguages();
const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
this.selectedTranscriptLang.set(prefLang || 'fr');
const base = prefLang.split('-')[0];
const initial = allowed.includes(prefLang) ? prefLang
: allowed.includes(base) ? base
: (allowed[0] || 'fr');
this.selectedTranscriptLang.set(initial);
} catch {
this.selectedTranscriptLang.set('fr');
}
@@ -1039,7 +1070,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
}
onTranscriptLangChange(value: string) {
const lang = String(value || '').trim().slice(0, 12) || 'fr';
const allowed = this.allowedTranscriptLanguages().map(l => l.toLowerCase());
let lang = String(value || '').trim().slice(0, 12) || 'fr';
if (!allowed.includes(lang.toLowerCase().split('-')[0])) {
lang = this.allowedTranscriptLanguages()[0] || 'fr';
}
this.selectedTranscriptLang.set(lang);
this.loadTranscript();
}
@@ -1321,15 +1356,18 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
}
/**
* Auto-fallback "n'importe quelle langue" : si la langue demandée échoue
* mais que le backend signale d'autres pistes, on relance automatiquement
* avec la première non encore tentée. Retourne true si un retry est parti.
* Auto-fallback restreint aux langues activées : si la langue demandée échoue
* mais que le backend signale d'autres pistes autorisées, on relance
* automatiquement avec la première non encore tentée. Retourne true si un retry est parti.
*/
private autoFallbackTranscriptLang(languages: unknown): boolean {
if (!Array.isArray(languages)) return false;
const allowed = new Set(this.allowedTranscriptLanguages().map(l => l.toLowerCase()));
const next = (languages as unknown[])
.map((l) => String(l || '').trim().slice(0, 12))
.find((l) => !!l && !this.transcriptTriedLangs.has(l.toLowerCase()));
.find((l) => !!l
&& allowed.has(l.toLowerCase().split('-')[0])
&& !this.transcriptTriedLangs.has(l.toLowerCase()));
if (!next) return false;
this.selectedTranscriptLang.set(next);
// loadTranscript() ré-enregistre dans triedLangs et relance la requête.
@@ -1348,14 +1386,27 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
}
// Never break the Watch page: every failure degrades to the "unavailable" notice.
try {
const allowed = this.allowedTranscriptLanguages();
if (!allowed.length) {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(false);
this.transcriptLanguages.set([]);
this.transcriptLines.set([]);
this.transcriptReason.set('no_subtitles');
return;
}
let requestedLang = this.selectedTranscriptLang();
if (!allowed.map(l => l.toLowerCase()).includes(String(requestedLang || '').toLowerCase().split('-')[0])) {
requestedLang = allowed[0] || 'fr';
this.selectedTranscriptLang.set(requestedLang);
}
this.transcriptLoading.set(true);
this.transcriptError.set(null);
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
const requestedLang = this.selectedTranscriptLang();
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
const opts = this.buildProviderOpts() as Record<string, string>;
const params = new URLSearchParams({ lang: requestedLang });
const params = new URLSearchParams({ lang: requestedLang, langs: allowed.join(',') });
if (opts['instance']) params.set('instance', opts['instance']);
if (opts['slug']) params.set('slug', opts['slug']);
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
@@ -1363,7 +1414,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
next: (data) => {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(!!data?.available);
this.transcriptLanguages.set(Array.isArray(data?.languages) ? data.languages : []);
this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages));
this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
if (data?.available && typeof data?.lang === 'string' && data.lang) {
this.selectedTranscriptLang.set(data.lang);
@@ -1390,7 +1441,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.transcriptLines.set([]);
const body = err?.error;
if (body && typeof body === 'object' && Array.isArray(body.languages)) {
this.transcriptLanguages.set(body.languages);
this.transcriptLanguages.set(this.filterTranscriptLanguages(body.languages));
// Erreur réseau/502 mais d'autres pistes connues : on essaie la
// suivante automatiquement (une fois chacune, garde anti-boucle).
if (this.autoFallbackTranscriptLang(body.languages)) return;