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(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 est réellement recréé par le binding. setTimeout(() => this.thumbSrc.set(url), 0); } buildQueryParams(v: VideoItem): Record { const qp: any = { p: v.provider }; if (v.provider === 'odysee' && v.slug) qp.slug = v.slug; // Twitch : chaînes/lives via ?channel=, clips via ?clip=. 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 = { 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()); } }