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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user