fix(search): loading par frame sur les cartes, plus de cadres vides
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
This commit is contained in:
2026-09-28 19:35:48 -04:00
parent f1f673040e
commit 1814334958
6 changed files with 151 additions and 11 deletions
@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core';
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';
@@ -15,8 +15,57 @@ import type { ChannelMeta, ProviderId } from '../../models/channel.model';
standalone: true,
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent],
})
export class VideoCardComponent {
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 };