feat(channel): page details chaine multi-providers avec onglets et infinite scroll
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:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
@@ -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); }