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,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),
});
}
}
}