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,9 +1,21 @@
<!-- Videos already loaded: keep them mounted during continuation loads (no scroll jump). -->
<div *ngIf="videos.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<app-video-card *ngFor="let video of videos; trackBy: trackById" [video]="video"></app-video-card>
<ng-container *ngIf="loadingMore">
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems"></app-video-card-skeleton>
</ng-container>
<div *ngIf="videos.length > 0">
<!-- Rafraichissement d'une grille deja peuplee : on garde les cartes et on
affiche un bandeau, plutot qu'un ecran vide. -->
<div *ngIf="loading" class="mb-4 flex items-center gap-2 text-sm text-zinc-400" role="status" aria-live="polite">
<span class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
<span>Chargement des resultats…</span>
<span class="h-1 flex-1 overflow-hidden rounded-full bg-zinc-800">
<span class="block h-full w-1/3 animate-pulse rounded-full bg-red-500/70"></span>
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<app-video-card *ngFor="let video of videos; let i = index; trackBy: trackById" [video]="video" [eager]="i < eagerCount"></app-video-card>
<ng-container *ngIf="loadingMore">
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems"></app-video-card-skeleton>
</ng-container>
</div>
</div>
<!-- Initial load only: skeleton grid while the first page arrives. -->
@@ -18,6 +18,8 @@ export class SearchResultGridComponent {
// Continuation load (videos already shown) -> keep grid, append trailing skeletons.
@Input() loadingMore = false;
@Input() skeletons = 12;
// Nombre de cartes chargées sans `loading="lazy"` (celles visibles au premier ecran).
@Input() eagerCount = 8;
get skeletonItems() {
return new Array(this.skeletons);
@@ -1,7 +1,15 @@
<div class="flex flex-col gap-2 animate-pulse">
<div class="aspect-video w-full rounded-lg bg-zinc-800"></div>
<div class="flex flex-col gap-2 animate-pulse" role="status" aria-label="Chargement…">
<div class="aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<div class="flex h-full w-full items-center justify-center">
<span class="h-7 w-7 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="h-5 w-3/4 rounded bg-zinc-800"></div>
<div class="h-4 w-1/2 rounded bg-zinc-800"></div>
</div>
<div class="flex items-center gap-2">
<div class="h-6 w-6 shrink-0 rounded-full bg-zinc-800"></div>
<div class="h-3 w-24 rounded bg-zinc-800"></div>
</div>
</div>
@@ -1,7 +1,33 @@
<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" loading="lazy" decoding="async" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<!-- Frame en cours de chargement : shimmer + spinner (plus de frame vide) -->
<div *ngIf="!thumbLoaded() && !thumbFailed()" class="absolute inset-0 animate-pulse bg-zinc-800/80" aria-hidden="true">
<div class="flex h-full w-full items-center justify-center">
<span class="h-7 w-7 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
</div>
</div>
<span *ngIf="!thumbLoaded() && !thumbFailed()" class="sr-only" role="status">Chargement de l'aperçu…</span>
<img *ngIf="thumbSrc()" [src]="thumbSrc()" [alt]="video.title"
[attr.loading]="eager ? 'eager' : 'lazy'" decoding="async"
(load)="onThumbLoad()" (error)="onThumbError()"
referrerpolicy="no-referrer"
class="h-full w-full object-cover transition-transform transition-opacity duration-200 group-hover:scale-105"
[class.opacity-0]="!thumbLoaded()" />
<!-- Miniature indisponible : on ne laisse jamais un cadre vide -->
<div *ngIf="thumbFailed()" class="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-zinc-800 px-2 text-center">
<svg class="h-6 w-6 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12A1.125 1.125 0 0119.75 21.75H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007Z" />
</svg>
<span class="text-[11px] leading-tight text-zinc-500">Aperçu indisponible</span>
<button *ngIf="thumbSrc()" type="button" (click)="retryThumb(); $event.preventDefault(); $event.stopPropagation()"
class="rounded bg-zinc-700/80 px-2 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-600">
Réessayer
</button>
</div>
<div class="absolute top-1 right-1">
<app-provider-badge [provider]="video.provider"></app-provider-badge>
</div>
@@ -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 };