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,61 @@
import { ChangeDetectionStrategy, Component, Input, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
import { ChannelsService } from '../../../../services/channels.service';
@Component({
selector: 'app-channel-link',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<ng-container *ngIf="hasDestination(); else plainLabel">
<a
[routerLink]="['/channel', provider, externalId]"
class="inline-flex items-center gap-2 font-medium transition hover:text-red-300"
(mouseenter)="prefetch()"
>
<span>{{ label() }}</span>
<ng-container *ngIf="channel()?.verified">
<span class="text-xs text-emerald-300">✓</span>
</ng-container>
</a>
</ng-container>
<ng-template #plainLabel>
<span class="font-medium">{{ label() }}</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelLinkComponent {
private channels = inject(ChannelsService);
@Input() provider: ProviderId | null = null;
@Input() externalId: string | null = null;
@Input() fallbackLabel: string | null = null;
@Input() eager = false;
private cached = signal<ChannelMeta | null>(null);
readonly channel = computed(() => this.cached());
label(): string {
const meta = this.cached();
return meta?.title || meta?.handle || this.fallbackLabel || 'Chaîne';
}
hasDestination(): boolean {
return Boolean(this.provider && this.externalId);
}
ngOnInit() {
if (this.eager) this.prefetch();
}
prefetch(): void {
if (!this.provider || !this.externalId) return;
if (this.cached()) return;
this.channels.get(this.provider, this.externalId).subscribe((meta) => {
if (meta) this.cached.set(meta);
});
}
}
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-compact-number',
standalone: true,
imports: [CommonModule],
template: `{{ formatted }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CompactNumberComponent {
@Input() value: number | null | undefined = null;
get formatted(): string {
const n = Number(this.value);
if (!Number.isFinite(n)) return '—';
try {
return new Intl.NumberFormat('fr-FR', { notation: 'compact', compactDisplay: 'short' }).format(n);
} catch {
return String(n);
}
}
}
@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ProviderId, ChannelMeta } from '../../models/channel.model';
import { SubscriptionsService } from '../../../../services/subscriptions.service';
@Component({
selector: 'app-subscribe-button',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[class.border-red-500/60]="isSubscribed()"
[class.bg-red-500/20]="isSubscribed()"
[class.text-red-100]="isSubscribed()"
[class.border-slate-600]="!isSubscribed()"
[class.bg-slate-800/70]="!isSubscribed()"
[class.text-slate-200]="!isSubscribed()"
[disabled]="isBusy()"
(click)="toggle()"
[attr.aria-pressed]="isSubscribed()"
>
<span *ngIf="!isSubscribed(); else unsub">S'abonner</span>
<ng-template #unsub>Se désabonner</ng-template>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscribeButtonComponent {
private readonly subs = inject(SubscriptionsService);
@Input({ required: true }) provider!: ProviderId;
@Input({ required: true }) externalId!: string;
@Input() channelMeta?: ChannelMeta | null;
@Input() optimistic = true;
@Output() subscribed = new EventEmitter<ChannelMeta | null>();
@Output() unsubscribed = new EventEmitter<void>();
private busy = signal(false);
readonly isBusy = computed(() => this.busy());
readonly isSubscribed = computed(() => this.subs.isSubscribed(this.provider, this.externalId));
toggle(): void {
if (this.busy()) return;
const subscribed = this.isSubscribed();
this.busy.set(true);
if (subscribed) {
const current = this.subs.items().find((item) => item.channel.provider === this.provider && item.channel.externalId === this.externalId);
if (!current) {
this.busy.set(false);
return;
}
this.subs.unsubscribe(current.subscriptionId).subscribe({
next: (ok) => {
if (ok) this.unsubscribed.emit();
},
complete: () => this.busy.set(false),
error: () => this.busy.set(false),
});
} else {
this.subs.subscribe(this.provider, this.externalId, { refresh: this.optimistic }).subscribe({
next: (item) => {
this.subscribed.emit(item?.channel ?? null);
},
complete: () => this.busy.set(false),
error: () => this.busy.set(false),
});
}
}
}
@@ -1,24 +1,94 @@
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<div class="absolute top-1 right-1">
<app-provider-badge [provider]="video.provider"></app-provider-badge>
<div class="flex flex-col gap-2">
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<div class="absolute top-1 right-1">
<app-provider-badge [provider]="video.provider"></app-provider-badge>
</div>
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | duration }}
</span>
</div>
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | duration }}
</span>
</div>
<div class="flex flex-col">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-sm text-zinc-400">
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
<div class="flex gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-xs text-zinc-400 flex gap-2">
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
</div>
</div>
</a>
<div class="flex items-center justify-between gap-2 text-sm text-zinc-400">
<ng-container *ngIf="channelProviderId as provider; else noProvider">
<ng-container *ngIf="channelExternalId() as externalId; else noLink">
<a
class="flex flex-1 items-center gap-2"
[routerLink]="['/channel', provider, externalId]"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<img
*ngIf="channelAvatarUrl(); else linkedInitials"
[src]="channelAvatarUrl()!"
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
class="h-full w-full object-cover"
loading="lazy"
/>
<ng-template #linkedInitials>
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
</span>
</ng-template>
</div>
<span class="font-medium text-zinc-200 hover:text-red-300">
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
</span>
</a>
<ng-container *ngIf="video.channelMeta as channel">
<app-subscribe-button
class="shrink-0"
[provider]="provider"
[externalId]="externalId"
[channelMeta]="channel"
[optimistic]="false"
></app-subscribe-button>
</ng-container>
</ng-container>
<ng-template #noLink>
<div class="flex flex-1 items-center gap-2">
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<img
*ngIf="channelAvatarUrl(); else noLinkInitials"
[src]="channelAvatarUrl()!"
[alt]="channelDisplayName(video.channelMeta ?? null, video.channelName)"
class="h-full w-full object-cover"
loading="lazy"
/>
<ng-template #noLinkInitials>
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(video.channelMeta ?? null, video.channelName) }}
</span>
</ng-template>
</div>
<span class="font-medium text-zinc-200">
{{ channelDisplayName(video.channelMeta ?? null, video.channelName) }}
</span>
</div>
</ng-template>
</ng-container>
<ng-template #noProvider>
<div class="flex flex-1 items-center gap-2">
<div class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-700 bg-zinc-800">
<span class="text-sm font-semibold text-zinc-200">
{{ channelInitial(null, video.channelName) }}
</span>
</div>
<span *ngIf="video.channelName" class="font-medium text-zinc-200">
{{ video.channelName }}
</span>
</div>
</ng-template>
</div>
</a>
</div>
@@ -4,13 +4,15 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DurationPipe } from '../../pipes/duration.pipe';
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
@Component({
selector: 'app-video-card',
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent],
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
@@ -21,4 +23,37 @@ export class VideoCardComponent {
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
return qp;
}
get channelProviderId(): ProviderId | null {
const map: Record<VideoItem['provider'], ProviderId> = {
youtube: 'yt',
dailymotion: 'dm',
twitch: 'tw',
peertube: 'pt',
odysee: 'od',
rumble: 'ru',
};
return map[this.video.provider] || null;
}
channelExternalId(): string | null {
return this.video.channelMeta?.externalId ?? this.video.channelExternalId ?? null;
}
channelAvatarUrl(): string | null {
return this.video.channelMeta?.avatarUrl ?? this.video.channelAvatarUrl ?? null;
}
channelDisplayName(channel?: ChannelMeta | null, fallback?: string | null): string {
const preferred = (channel?.title || channel?.handle || fallback || '').toString().trim();
if (preferred.length > 0) {
return preferred;
}
return (channel?.externalId || '').trim();
}
channelInitial(channel?: ChannelMeta | null, fallback?: string | null): string {
const name = this.channelDisplayName(channel, fallback);
return name ? name.charAt(0).toUpperCase() : 'C';
}
}