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
@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
@Component({
selector: 'app-channel-about',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div *ngIf="channel" class="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900/50 p-5 text-sm text-zinc-300 lg:grid-cols-2">
<div>
<h3 class="mb-2 text-base font-semibold text-white">Description</h3>
<p class="whitespace-pre-line">{{ channel.description || 'Aucune description disponible.' }}</p>
</div>
<div class="space-y-2">
<h3 class="text-base font-semibold text-white">Informations</h3>
<p *ngIf="channel.handle"><span class="text-zinc-500">Handle : </span>{{ channel.handle }}</p>
<p><span class="text-zinc-500">ID externe : </span><span class="font-mono text-xs">{{ channel.externalId }}</span></p>
<p *ngIf="channel.viewCount != null"><span class="text-zinc-500">Vues totales : </span>{{ channel.viewCount | number }}</p>
<p *ngIf="channel.createdAt"><span class="text-zinc-500">Créée le : </span>{{ channel.createdAt | date:'longDate' }}</p>
<p *ngIf="channel.lastRefreshedAt"><span class="text-zinc-500">Dernière synchro : </span>{{ channel.lastRefreshedAt | date:'medium' }}</p>
<div *ngIf="channel.links?.length">
<p class="text-zinc-500">Liens :</p>
<ul class="mt-1 space-y-1">
<li *ngFor="let link of channel.links">
<a [href]="link.url" target="_blank" rel="noopener" class="text-red-300 hover:text-red-200 hover:underline">{{ link.label }}</a>
</li>
</ul>
</div>
<a *ngIf="channel.url" [href]="channel.url" target="_blank" rel="noopener" class="inline-block rounded-md border border-zinc-700 px-3 py-1.5 text-zinc-200 hover:border-zinc-500 hover:text-white">
Ouvrir la chaîne officielle
</a>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelAboutComponent {
@Input() channel: ChannelDetail | null = null;
}
@@ -0,0 +1,83 @@
<div *ngIf="loading" class="animate-pulse">
<div class="h-36 w-full rounded-xl bg-zinc-800 sm:h-48"></div>
<div class="mt-4 flex items-center gap-4 px-1">
<div class="h-24 w-24 rounded-full bg-zinc-800"></div>
<div class="flex-1 space-y-2">
<div class="h-6 w-1/3 rounded bg-zinc-800"></div>
<div class="h-4 w-1/4 rounded bg-zinc-800"></div>
</div>
</div>
</div>
<div *ngIf="!loading && channel" class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950">
<!-- Bannière (masquée proprement si absente) -->
<div *ngIf="channel.bannerUrl" class="h-36 w-full overflow-hidden bg-zinc-900 sm:h-48">
<img [src]="channel.bannerUrl" [alt]="" class="h-full w-full object-cover" loading="lazy" />
</div>
<div class="flex flex-col gap-4 p-4 sm:p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="relative -mt-2 shrink-0 sm:-mt-10">
<img
[src]="channel.avatarUrl || 'https://placehold.co/160x160/png'"
[alt]="channel.title || channel.handle || 'Avatar'"
class="h-24 w-24 rounded-full border-4 border-zinc-950 bg-zinc-800 object-cover sm:h-28 sm:w-28"
/>
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-bold text-white sm:text-3xl">
{{ channel.title || channel.handle || 'Chaîne inconnue' }}
</h1>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-400">
<app-provider-badge [provider]="longProvider"></app-provider-badge>
<span *ngIf="channel.handle" class="truncate">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
<app-compact-number [value]="channel.subsCount"></app-compact-number> abonnés
</span>
<span *ngIf="channel.videoCount != null">
<app-compact-number [value]="channel.videoCount"></app-compact-number> vidéos
</span>
<span *ngIf="channel.verified" class="font-medium text-emerald-300">✓ Vérifiée</span>
</div>
<!-- Description tronquée + ...plus -->
<div *ngIf="channel.description" class="mt-2 max-w-3xl text-sm text-zinc-300">
<p [class.line-clamp-2]="!expanded()">{{ channel.description }}</p>
<button type="button" (click)="toggleExpand()" class="mt-1 font-medium text-zinc-100 hover:text-red-300">
{{ expanded() ? '...moins' : '...plus' }}
</button>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<app-subscribe-button
[provider]="channel.provider"
[externalId]="channel.externalId"
[channelMeta]="channel"
></app-subscribe-button>
<button
type="button"
(click)="share()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Partager
</button>
<a
*ngIf="channel.url"
[href]="channel.url"
target="_blank"
rel="noopener"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Ouvrir la chaîne officielle
</a>
<button
type="button"
(click)="refresh.emit()"
class="rounded-md border border-zinc-700 px-3 py-1.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500 hover:text-white"
>
Rafraîchir
</button>
</div>
</div>
</div>
@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelDetail } from '../../../app/shared/models/channel-detail.model';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
import { CompactNumberComponent } from '../../../app/shared/components/compact-number/compact-number.component';
@Component({
selector: 'app-channel-header',
standalone: true,
imports: [CommonModule, ProviderBadgeComponent, SubscribeButtonComponent, CompactNumberComponent],
templateUrl: './channel-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelHeaderComponent {
@Input() channel: ChannelDetail | null = null;
@Input() loading = false;
@Output() refresh = new EventEmitter<void>();
readonly expanded = signal(false);
toggleExpand(): void {
this.expanded.update((v) => !v);
}
share(): void {
const url = window.location.href;
const title = this.channel?.title || this.channel?.handle || 'Chaîne';
if (navigator.share) {
navigator.share({ title, url }).catch(() => {});
} else {
navigator.clipboard?.writeText(url).catch(() => {});
}
}
get longProvider(): string | null {
const map: Record<string, string> = {
yt: 'youtube', dm: 'dailymotion', tw: 'twitch', pt: 'peertube', od: 'odysee', ru: 'rumble',
};
return this.channel ? (map[this.channel.provider] ?? null) : null;
}
}
@@ -0,0 +1,97 @@
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Erreur header (params invalides / chaîne introuvable) -->
<div *ngIf="headerError() && !headerLoading()" class="rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h2 class="mb-2 text-xl font-semibold text-red-100">Impossible de charger la chaîne</h2>
<p class="text-sm text-red-200/80">{{ headerError() }}</p>
<button type="button" (click)="refreshAll(true)" class="mt-4 rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</div>
<div *ngIf="!headerError() || headerLoading()">
<app-channel-header [channel]="detail()" [loading]="headerLoading()" (refresh)="refreshAll(true)"></app-channel-header>
<div *ngIf="!headerLoading() && detail()" class="mt-4">
<app-channel-tabs [capabilities]="capabilities()" [active]="activeTab()" (select)="onTabSelect($event)"></app-channel-tabs>
<!-- Barre d'outils : tri + recherche locale (masquée sur À propos) -->
<div *ngIf="activeTab() !== 'about'" class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center">
<div *ngIf="activeTab() === 'videos' || activeTab() === 'shorts'" class="flex items-center gap-2 text-sm">
<label for="channel-sort" class="text-zinc-400">Trier :</label>
<select
id="channel-sort"
[value]="sort()"
(change)="onSortChange($any($event.target).value)"
class="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100"
>
<option value="recent">Plus récentes</option>
<option value="popular">Plus populaires</option>
</select>
</div>
<div class="relative flex-1 sm:max-w-md sm:ml-auto">
<input
type="search"
placeholder="Rechercher dans cette chaîne…"
[value]="searchQuery()"
(input)="onSearchInput($any($event.target).value)"
class="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 placeholder:text-zinc-500 focus:border-red-500 focus:outline-none"
/>
<button *ngIf="searchQuery()" type="button" (click)="clearSearch()" aria-label="Effacer"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded px-1 text-zinc-400 hover:text-white">✕</button>
</div>
</div>
<!-- Onglets contenu -->
<div class="pb-10">
<app-channel-video-grid
*ngIf="activeTab() === 'videos'"
[videos]="videos()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucune vidéo trouvée pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-video-grid
*ngIf="activeTab() === 'shorts'"
[videos]="shorts()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="short"
emptyMessage="Aucun short trouvé pour cette chaîne."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-playlists
*ngIf="activeTab() === 'playlists'"
[playlists]="playlists()"
[loading]="listLoading()"
[error]="listError()"
(retry)="onRetryTab()"
></app-channel-playlists>
<app-channel-video-grid
*ngIf="activeTab() === 'live'"
[videos]="streams()"
[loading]="listLoading()"
[loadingMore]="listLoadingMore()"
[hasMore]="hasMore()"
[error]="listError()"
layout="video"
emptyMessage="Aucun stream en direct ou replay pour le moment."
(loadMore)="onLoadMore()"
(retry)="onRetryTab()"
></app-channel-video-grid>
<app-channel-about *ngIf="activeTab() === 'about'" [channel]="detail()"></app-channel-about>
</div>
</div>
</div>
</div>
@@ -0,0 +1,296 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, debounceTime, distinctUntilChanged, map, of, switchMap, take } from 'rxjs';
import type { ProviderId } from '../../app/shared/models/channel.model';
import type {
ChannelDetail,
ChannelPlaylist,
ChannelSort,
ChannelTabId,
} from '../../app/shared/models/channel-detail.model';
import { buildChannelDetail } from '../../app/shared/models/channel-detail.model';
import type { VideoItem } from '../../app/shared/models/video-item.model';
import { normalizeChannelProvider, ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
import { ChannelsService } from '../../services/channels.service';
import { ChannelContentService } from '../../services/channel-content.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import { ChannelHeaderComponent } from './channel-header/channel-header.component';
import { ChannelTabsComponent } from './channel-tabs/channel-tabs.component';
import { ChannelVideoGridComponent } from './channel-video-grid/channel-video-grid.component';
import { ChannelPlaylistsComponent } from './channel-playlists/channel-playlists.component';
import { ChannelAboutComponent } from './channel-about/channel-about.component';
@Component({
selector: 'app-channel-page',
standalone: true,
imports: [
CommonModule,
ChannelHeaderComponent,
ChannelTabsComponent,
ChannelVideoGridComponent,
ChannelPlaylistsComponent,
ChannelAboutComponent,
],
templateUrl: './channel-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly channels = inject(ChannelsService);
private readonly content = inject(ChannelContentService);
private readonly providers = inject(ChannelProviderFactory);
private readonly subs = inject(SubscriptionsService);
private readonly searchInput$ = new Subject<string>();
readonly provider = signal<ProviderId | null>(null);
readonly externalId = signal<string | null>(null);
readonly detail = signal<ChannelDetail | null>(null);
readonly headerLoading = signal(true);
readonly headerError = signal<string | null>(null);
readonly activeTab = signal<ChannelTabId>('videos');
readonly sort = signal<ChannelSort>('recent');
readonly searchQuery = signal('');
readonly videos = signal<VideoItem[]>([]);
readonly shorts = signal<VideoItem[]>([]);
readonly streams = signal<VideoItem[]>([]);
readonly playlists = signal<ChannelPlaylist[]>([]);
readonly listLoading = signal(false);
readonly listLoadingMore = signal(false);
readonly listError = signal<string | null>(null);
readonly hasMore = signal(false);
readonly capabilities = computed(() => this.detail()?.capabilities ?? null);
constructor() {
if (this.subs.items().length === 0) {
this.subs.list().pipe(takeUntilDestroyed()).subscribe();
}
// Recherche locale débouncée -> refetch via searchInChannel (ou filtre)
this.searchInput$
.pipe(debounceTime(400), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((q) => {
this.searchQuery.set(q);
this.syncQueryParams();
this.reloadTab(true);
});
this.route.paramMap
.pipe(
map((params) => ({
provider: normalizeChannelProvider(params.get('provider')),
externalId: params.get('externalId')?.trim() || null,
})),
switchMap(({ provider, externalId }) => {
this.provider.set(provider);
this.externalId.set(externalId);
// Onglet / tri / recherche depuis l'URL (?tab=&sort=&q=)
const qp = this.route.snapshot.queryParamMap;
const tab = qp.get('tab') as ChannelTabId | null;
if (tab && ['videos', 'shorts', 'playlists', 'live', 'about'].includes(tab)) this.activeTab.set(tab);
const sort = qp.get('sort') as ChannelSort | null;
if (sort === 'popular' || sort === 'recent') this.sort.set(sort);
this.searchQuery.set(qp.get('q') ?? '');
if (!provider || !externalId) {
this.headerLoading.set(false);
this.headerError.set('channel_invalid_params');
this.detail.set(null);
return of(null);
}
this.headerLoading.set(true);
this.headerError.set(null);
const refresh = qp.get('refresh') === '1' || qp.get('refresh') === 'true';
// Si ?refresh=1, vide aussi le cache des listes
if (refresh) this.content.clear(provider, externalId);
return this.channels.resolve(provider, externalId, { refresh });
}),
takeUntilDestroyed(),
)
.subscribe((meta) => {
this.headerLoading.set(false);
if (!meta) {
this.headerError.set('channel_unavailable');
this.detail.set(null);
return;
}
// Enrichit la meta (cache 6h) en ChannelDetail avec capabilities du provider
const providerApi = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
capabilities: {
videos: true,
shorts: providerApi.supports('shorts'),
playlists: providerApi.supports('playlists'),
live: providerApi.supports('live'),
about: true,
searchInChannel: providerApi.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
// Si l'onglet actif n'est pas supporté, retomber sur videos
const capsNow = this.detail()?.capabilities;
if (capsNow && !(capsNow as unknown as Record<string, boolean>)[this.activeTab()]) {
this.activeTab.set('videos');
}
this.reloadTab();
});
}
onTabSelect(tab: ChannelTabId): void {
if (tab === this.activeTab()) return;
this.activeTab.set(tab);
this.syncQueryParams();
this.reloadTab();
}
onSortChange(value: string): void {
this.sort.set(value === 'popular' ? 'popular' : 'recent');
this.syncQueryParams();
this.reloadTab(true);
}
onSearchInput(value: string): void {
this.searchInput$.next(value);
}
clearSearch(): void {
this.searchQuery.set('');
this.syncQueryParams();
this.reloadTab(true);
}
onLoadMore(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id || this.listLoadingMore() || this.listLoading() || !this.hasMore()) return;
this.listLoadingMore.set(true);
const type = this.tabToContentType();
if (!type) return;
this.content
.loadMore(p, id, type, { sort: this.sort(), q: this.searchQuery() })
.pipe(take(1))
.subscribe((ok) => {
this.listLoadingMore.set(false);
if (ok) this.pullState();
});
}
onRetryTab(): void {
this.reloadTab(true);
}
refreshAll(force = true): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.headerLoading.set(true);
if (force) this.content.clear(p, id);
this.channels
.resolve(p, id, { refresh: force })
.pipe(take(1))
.subscribe({
next: (meta) => {
if (meta) {
const api = this.providers.get(meta.provider);
this.detail.set(
buildChannelDetail(meta, {
description: this.detail()?.description ?? null,
capabilities: {
videos: true,
shorts: api.supports('shorts'),
playlists: api.supports('playlists'),
live: api.supports('live'),
about: true,
searchInChannel: api.supports('searchInChannel'),
},
}),
);
this.headerError.set(null);
} else {
this.headerError.set('channel_unavailable');
}
},
error: () => this.headerError.set('channel_unavailable'),
complete: () => {
this.headerLoading.set(false);
this.reloadTab(true);
},
});
}
private tabToContentType(): 'videos' | 'shorts' | 'playlists' | 'live' | null {
switch (this.activeTab()) {
case 'videos': return 'videos';
case 'shorts': return 'shorts';
case 'playlists': return 'playlists';
case 'live': return 'live';
default: return null;
}
}
private reloadTab(force = false): void {
const p = this.provider();
const id = this.externalId();
const tab = this.activeTab();
if (!p || !id || tab === 'about') return;
this.listLoading.set(true);
this.listError.set(null);
const sort = this.sort();
const q = this.searchQuery();
const done = (ok: boolean) => {
this.listLoading.set(false);
if (!ok) this.listError.set('channel_content_failed');
this.pullState();
};
if (tab === 'videos') {
this.content.loadVideos(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'shorts') {
this.content.loadShorts(p, id, { sort, q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'live') {
this.content.loadStreams(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
} else if (tab === 'playlists') {
this.content.loadPlaylists(p, id, { q, refresh: force }).pipe(take(1)).subscribe({ next: () => done(true), error: () => done(false) });
}
}
private pullState(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
const sort = this.sort();
const q = this.searchQuery();
const tab = this.activeTab();
if (tab === 'videos') {
const s = this.content.snapshot<VideoItem>(p, id, 'videos', sort, q);
this.videos.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'shorts') {
const s = this.content.snapshot<VideoItem>(p, id, 'shorts', sort, q);
this.shorts.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'live') {
const s = this.content.snapshot<VideoItem>(p, id, 'live', 'recent', q);
this.streams.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
} else if (tab === 'playlists') {
const s = this.content.snapshot<ChannelPlaylist>(p, id, 'playlists', 'recent', q);
this.playlists.set(s.items); this.listError.set(s.error); this.hasMore.set(s.nextPage != null);
}
}
private syncQueryParams(): void {
const p = this.provider();
const id = this.externalId();
if (!p || !id) return;
this.router.navigate(['/channel', p, id], {
queryParams: { tab: this.activeTab(), sort: this.sort(), q: this.searchQuery() || null },
queryParamsHandling: 'merge',
replaceUrl: true,
}).catch(() => {});
}
}
@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelPlaylist } from '../../../app/shared/models/channel-detail.model';
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-channel-playlists',
standalone: true,
imports: [CommonModule, VideoCardSkeletonComponent],
template: `
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5]"></app-video-card-skeleton>
</div>
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger les playlists</h3>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm text-red-100 hover:bg-red-500/20">Réessayer</button>
</div>
<div *ngIf="!loading && !error && playlists.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">Aucune playlist trouvée</h3>
<p class="text-sm text-zinc-400">Cette chaîne ne propose pas encore de playlists.</p>
</div>
<div *ngIf="!loading && !error && playlists.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div *ngFor="let pl of playlists" class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 transition hover:border-zinc-600">
<div class="relative aspect-video w-full overflow-hidden bg-zinc-800">
<img *ngIf="pl.thumbnailUrl; else noThumb" [src]="pl.thumbnailUrl!" [alt]="pl.title" loading="lazy" class="h-full w-full object-cover" />
<ng-template #noThumb><div class="flex h-full w-full items-center justify-center text-4xl text-zinc-600">▤</div></ng-template>
<div class="absolute inset-y-0 right-0 flex w-24 flex-col items-center justify-center gap-1 bg-black/70 text-white">
<span class="text-xl font-bold">{{ pl.videoCount ?? '–' }}</span>
<span class="text-xs">vidéos</span>
</div>
</div>
<div class="p-3">
<h3 class="line-clamp-2 text-sm font-semibold text-zinc-100">{{ pl.title }}</h3>
<p *ngIf="pl.updatedAt" class="mt-1 text-xs text-zinc-400">Mis à jour le {{ pl.updatedAt | date:'shortDate' }}</p>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelPlaylistsComponent {
@Input() playlists: ChannelPlaylist[] = [];
@Input() loading = false;
@Input() error: string | null = null;
@Output() retry = new EventEmitter<void>();
}
@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ChannelCapabilities, ChannelTabId } from '../../../app/shared/models/channel-detail.model';
export interface ChannelTabDef {
id: ChannelTabId;
label: string;
}
const ALL_TABS: ChannelTabDef[] = [
{ id: 'videos', label: 'Vidéos' },
{ id: 'shorts', label: 'Shorts' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'live', label: 'En direct' },
{ id: 'about', label: 'À propos' },
];
@Component({
selector: 'app-channel-tabs',
standalone: true,
imports: [CommonModule],
template: `
<nav class="sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 backdrop-blur" aria-label="Onglets chaîne">
<div class="flex gap-1 overflow-x-auto py-2" role="tablist">
<button
*ngFor="let tab of visibleTabs"
type="button"
role="tab"
[attr.aria-selected]="tab.id === active"
(click)="select.emit(tab.id)"
class="whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition"
[class.bg-red-500/20]="tab.id === active"
[class.text-red-100]="tab.id === active"
[class.text-zinc-400]="tab.id !== active"
[class.hover:text-white]="tab.id !== active"
>
{{ tab.label }}
</button>
</div>
</nav>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelTabsComponent {
@Input() capabilities: ChannelCapabilities | null = null;
@Input() active: ChannelTabId = 'videos';
@Output() select = new EventEmitter<ChannelTabId>();
get visibleTabs(): ChannelTabDef[] {
if (!this.capabilities) return [{ id: 'videos', label: 'Vidéos' }];
return ALL_TABS.filter((t) => (this.capabilities as unknown as Record<string, boolean>)[t.id]);
}
}
@@ -0,0 +1,46 @@
<!-- 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">
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
</div>
<!-- Erreur -->
<div *ngIf="!loading && error" class="flex flex-col items-center gap-3 rounded-xl border border-red-500/40 bg-red-500/10 p-8 text-center">
<h3 class="text-lg font-semibold text-red-100">Impossible de charger le contenu</h3>
<p class="text-sm text-red-200/80">{{ error }}</p>
<button type="button" (click)="retry.emit()" class="rounded-md border border-red-500/60 px-4 py-1.5 text-sm font-medium text-red-100 hover:bg-red-500/20">
Réessayer
</button>
</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">
<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>
</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 }"
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" />
</div>
<div class="p-2">
<h3 class="line-clamp-2 text-sm font-medium text-zinc-100">{{ video.title }}</h3>
<p *ngIf="video.viewCount" class="mt-1 text-xs text-zinc-400">{{ video.viewCount | number }} vues</p>
</div>
</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">
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
Chargement…
</div>
</div>
@@ -0,0 +1,69 @@
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';
@Component({
selector: 'app-channel-video-grid',
standalone: true,
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
templateUrl: './channel-video-grid.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() loadingMore = false;
@Input() hasMore = false;
@Input() error: string | null = null;
/** 'video' (16:9) ou 'short' (9:16) */
@Input() layout: 'video' | 'short' = 'video';
@Input() emptyMessage = 'Aucune vidéo trouvée.';
@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);
}
}