CI / build-and-test (push) Successful in 14m8s
- video-card: etat de chargement de la miniature (shimmer + spinner), fallback 'Apertu indisponible' avec retry, referrerpolicy no-referrer - grid: eager sur les premieres cartes, bandeau de rafraichissement quand la grille est deja peuplee - search: garde MIN_QUERY_LENGTH=2 (le loading partait pour une requete que request$ filtrait, grille figee sur des skeletons) + filet de securite 20s - skeleton: ligne chaine pour coller a la hauteur reelle de la carte
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { Component, Input, OnChanges, SimpleChanges, signal } from '@angular/core';
|
|
import { VideoItem } from '../../models/video-item.model';
|
|
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 { ChannelIdentityComponent } from '../channel-identity/channel-identity.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, SubscribeButtonComponent, ChannelIdentityComponent],
|
|
})
|
|
export class VideoCardComponent implements OnChanges {
|
|
@Input() video!: VideoItem;
|
|
/** Cartes du haut de la grille : chargement immédiat (lazy désactivé). */
|
|
@Input() eager = false;
|
|
|
|
/**
|
|
* État de chargement de la frame (miniature). Tant que l'image n'a pas
|
|
* répondu, la frame affiche un shimmer + spinner au lieu d'un rectangle vide.
|
|
*/
|
|
readonly thumbSrc = signal<string | null>(null);
|
|
readonly thumbLoaded = signal(false);
|
|
readonly thumbFailed = signal(false);
|
|
|
|
ngOnChanges(_changes: SimpleChanges): void {
|
|
this.syncThumb();
|
|
}
|
|
|
|
private syncThumb(): void {
|
|
const raw = (this.video as any)?.thumbnailUrl;
|
|
const url = typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : null;
|
|
if (url === this.thumbSrc()) {
|
|
// Meme source : le navigateur ne rejouera pas `load`, on garde l'etat
|
|
// courant (sinon le spinner resterait bloque indefiniment).
|
|
this.thumbFailed.set(!url);
|
|
return;
|
|
}
|
|
this.thumbSrc.set(url);
|
|
this.thumbFailed.set(!url);
|
|
this.thumbLoaded.set(false);
|
|
}
|
|
|
|
onThumbLoad(): void {
|
|
this.thumbLoaded.set(true);
|
|
this.thumbFailed.set(false);
|
|
}
|
|
|
|
onThumbError(): void {
|
|
this.thumbLoaded.set(false);
|
|
this.thumbFailed.set(true);
|
|
}
|
|
|
|
/** Relance l'image après un échec (hotlink bloqué / 403 / réseau). */
|
|
retryThumb(): void {
|
|
const url = this.thumbSrc();
|
|
if (!url) return;
|
|
this.thumbFailed.set(false);
|
|
this.thumbLoaded.set(false);
|
|
this.thumbSrc.set(null);
|
|
// Relance après un tick : le <img> est réellement recréé par le binding.
|
|
setTimeout(() => this.thumbSrc.set(url), 0);
|
|
}
|
|
|
|
buildQueryParams(v: VideoItem): Record<string, any> {
|
|
const qp: any = { p: v.provider };
|
|
if (v.provider === 'odysee' && v.slug) qp.slug = v.slug;
|
|
// Twitch : chaînes/lives via ?channel=<login>, clips via ?clip=<slug>.
|
|
if (v.provider === 'twitch') {
|
|
const kind = String((v as any)?.type || '').toLowerCase();
|
|
const clipKind = String((v as any)?.kind || '').toLowerCase();
|
|
if (clipKind === 'clip') qp.clip = v.id;
|
|
else if ((kind === 'live' || kind === 'channel') && v.channel) qp.channel = v.channel;
|
|
}
|
|
return qp;
|
|
}
|
|
|
|
get isTwitchLive(): boolean {
|
|
const v: any = this.video;
|
|
return v?.provider === 'twitch' && (v?.isLive === true || String(v?.type || '').toLowerCase() === 'live');
|
|
}
|
|
|
|
get isTwitchChannel(): boolean {
|
|
const v: any = this.video;
|
|
return v?.provider === 'twitch' && String(v?.type || '').toLowerCase() === 'channel' && v?.isLive !== true;
|
|
}
|
|
|
|
get isTwitchClip(): boolean {
|
|
const v: any = this.video;
|
|
return v?.provider === 'twitch' && String(v?.kind || '').toLowerCase() === 'clip';
|
|
}
|
|
|
|
get showDuration(): boolean {
|
|
const d = Number((this.video as any)?.durationSec ?? NaN);
|
|
return Number.isFinite(d) && d > 0;
|
|
}
|
|
|
|
get providerId(): string {
|
|
return String((this.video as any)?.provider || '');
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
get showSubscribe(): boolean {
|
|
return Boolean(this.video.channelMeta && this.channelProviderId && this.channelExternalId());
|
|
}
|
|
}
|