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
@@ -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';
}
}