feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
CI / build-and-test (push) Successful in 13m34s
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts) - ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage - ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error - Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
@@ -24,6 +24,25 @@
|
||||
</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">
|
||||
<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'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts<span *ngIf="allVideos().length"> ({{ shortsCount() }})</span>
|
||||
</button>
|
||||
</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">
|
||||
@@ -107,6 +126,10 @@
|
||||
<div class="absolute top-2 right-2">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -10,7 +10,9 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { map, of, switchMap } from 'rxjs';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { map, of, switchMap, catchError, Observable } from 'rxjs';
|
||||
import { PagedResult } from '../../services/youtube-api.service';
|
||||
|
||||
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
trending: ['just chatting', 'highlights'],
|
||||
@@ -141,18 +143,32 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
const themeSlug = this.theme();
|
||||
const isLiveTheme = themeSlug === 'live';
|
||||
|
||||
let apiCall;
|
||||
let apiCall: Observable<PagedResult<Video>>;
|
||||
if (provider === 'twitch') {
|
||||
if (isLiveTheme) {
|
||||
apiCall = this.api.searchTwitchChannelsPage(query || 'live', cursor || undefined);
|
||||
// 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 || [])] }))
|
||||
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))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -164,11 +180,11 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
apiCall.subscribe({
|
||||
next: (res) => {
|
||||
next: (res: PagedResult<Video>) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
|
||||
const existingIds = new Set(this.allVideos().map(v => v.videoId));
|
||||
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
|
||||
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
||||
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
|
||||
|
||||
this.allVideos.update(current => [...current, ...newVideos]);
|
||||
this.nextCursor.set(res.nextCursor || null);
|
||||
@@ -199,11 +215,14 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
|
||||
const typeFilter = this.type();
|
||||
if (typeFilter !== 'any') {
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => {
|
||||
if (typeFilter === 'video') return v.type === 'video';
|
||||
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
|
||||
if (typeFilter === 'recorded') return v.type === 'recorded';
|
||||
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
|
||||
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);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -252,6 +271,27 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
onSortChange(val: any) { this.sort.set(val as any); }
|
||||
onDurationChange(val: any) { this.duration.set(val as any); }
|
||||
onTypeChange(val: any) { this.type.set(val as any); }
|
||||
|
||||
// Raccourci Vidéos / Shorts synchronisé avec le filtre "type" existant.
|
||||
contentFilter = computed<'all' | 'videos' | 'shorts'>(() => {
|
||||
const t = this.type();
|
||||
if (t === 'short') return 'shorts';
|
||||
if (t === 'video') return 'videos';
|
||||
return 'all';
|
||||
});
|
||||
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
|
||||
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
|
||||
}
|
||||
|
||||
kindOf(v: Video): string {
|
||||
const p = this.provider();
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || p });
|
||||
}
|
||||
|
||||
shortsCount(): number {
|
||||
const p = this.provider();
|
||||
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
|
||||
}
|
||||
onLanguageChange(val: any) { this.language.set(val as any); }
|
||||
onDateChange(val: any) { this.date.set(val as any); }
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<h2 class="text-3xl font-bold mb-4 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
<!-- Filtre Vidéos / Shorts : les deux formats ne sont plus mélangés sans contrôle -->
|
||||
<div class="flex items-center gap-2 mb-6" 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'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setContentFilter('videos')" [attr.aria-pressed]="contentFilter() === 'videos'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'videos' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos
|
||||
</button>
|
||||
<button (click)="setContentFilter('shorts')" [attr.aria-pressed]="contentFilter() === 'shorts'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'shorts' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Shorts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-10">
|
||||
<section *ngFor="let b of blocks()">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -18,10 +37,13 @@
|
||||
<!-- 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 && filteredItems(b).length === 0" class="text-slate-400">
|
||||
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<a *ngFor="let v of b.items"
|
||||
<div *ngIf="!b.loading && !b.error && filteredItems(b).length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<a *ngFor="let v of filteredItems(b)"
|
||||
[routerLink]="['/watch', v.videoId]"
|
||||
[queryParams]="watchQueryParams(b.provider, v)"
|
||||
[state]="{ video: v }"
|
||||
@@ -31,6 +53,10 @@
|
||||
<div class="absolute top-2 right-2">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@@ -39,6 +40,30 @@ export class ThemePageComponent {
|
||||
|
||||
blocks = signal<ProviderBlock[]>([]);
|
||||
|
||||
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
|
||||
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
|
||||
|
||||
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
|
||||
this.contentFilter.set(v);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts. */
|
||||
filteredItems(b: ProviderBlock): Video[] {
|
||||
const f = this.contentFilter();
|
||||
if (f === 'all') return b.items;
|
||||
return b.items.filter(v =>
|
||||
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
|
||||
);
|
||||
}
|
||||
|
||||
kindOf(v: Video, provider: Provider): string {
|
||||
return classifyVideo({ ...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;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const slug = pm.get('theme') || pm.get('slug') || 'trending';
|
||||
@@ -115,6 +140,35 @@ 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({
|
||||
next: res => {
|
||||
block.items = (res.items || []).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' });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Twitch hors live/trending : VODs + clips + fallback top streams.
|
||||
if (block.provider === 'twitch') {
|
||||
const q = tokens.join(' ');
|
||||
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
|
||||
call.subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const q = tokens.join(' ');
|
||||
if (!q) {
|
||||
// fallback to trending
|
||||
|
||||
Reference in New Issue
Block a user