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,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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user