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 };
+44 -1
View File
@@ -22,6 +22,11 @@ import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { Observable, Subscription } from 'rxjs';
/** Longueur minimale d'une requete (aligne sur `SearchService.request$` et le backend). */
const MIN_QUERY_LENGTH = 2;
/** Filet de securite : le loading ne doit jamais rester bloque indefiniment. */
const LOADING_TIMEOUT_MS = 20_000;
@Component({
selector: 'app-search',
standalone: true,
@@ -69,6 +74,8 @@ export class SearchComponent {
private lastRecordedKey: string | null = null;
// Signature to detect new searches (q + providers + sort); excludes page
private lastSearchSignature: string | null = null;
// Relache le loading si l'emetteur `request$` ne repond pas (filet de securite).
private loadingWatchdog: ReturnType<typeof setTimeout> | null = null;
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@@ -291,6 +298,7 @@ export class SearchComponent {
}
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
this.error.set(null);
// Surface per-provider backend failures (e.g. YouTube quota) so
// users understand why only some providers return results.
@@ -300,6 +308,7 @@ export class SearchComponent {
this.groups.set({} as any);
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
}
});
@@ -340,7 +349,7 @@ export class SearchComponent {
this.typeFilter.set(filters.type);
this.dateFilter.set(filters.period);
if (q) {
if (q.length >= MIN_QUERY_LENGTH) {
this.notice.set(null);
// Reset the language filter on a new query (the other filters are URL
// driven and must survive a query change).
@@ -385,15 +394,18 @@ export class SearchComponent {
this.endReached.set(false);
this.loadingMore.set(false);
this.loading.set(true);
this.armLoadingWatchdog();
// New search (or sort/filter reset): results restart, scroll to top.
// Continuation pages must NOT touch scroll (handled without router nav).
try { window.scrollTo(0, 0); } catch {}
} else if (!this.groups() || Object.keys(this.groups()).length === 0) {
this.loading.set(true);
this.armLoadingWatchdog();
}
this.error.set(null);
} else {
this.loading.set(false);
this.clearLoadingWatchdog();
this.groups.set({} as any);
this.showUnified.set(false);
this.error.set(null);
@@ -403,6 +415,36 @@ export class SearchComponent {
}
/**
* Arme le filet de securite du loading. Si `request$` ne repond pas (requete
* supprimee par un `switchMap`, cache incoherent, etc.) on stoppe le loading au
* lieu de laisser une grille figee sur des skeletons.
*/
private armLoadingWatchdog(): void {
this.clearLoadingWatchdog();
this.loadingWatchdog = setTimeout(() => {
this.loadingWatchdog = null;
if (this.loading()) {
this.loading.set(false);
this.loadingMore.set(false);
if (Object.keys(this.groups() || {}).length === 0) {
this.error.set('La recherche met trop de temps a repondre. Reessayez.');
}
}
}, LOADING_TIMEOUT_MS);
}
private clearLoadingWatchdog(): void {
if (this.loadingWatchdog != null) {
clearTimeout(this.loadingWatchdog);
this.loadingWatchdog = null;
}
}
ngOnDestroy(): void {
this.clearLoadingWatchdog();
}
// Lectures de métadonnées déléguées au modèle de filtres partagé
// (server/search-filters.mjs côté API, src/app/search/filters.ts côté front :
// même implémentation = mêmes résultats).
@@ -735,6 +777,7 @@ export class SearchComponent {
const next = (this.pageParam() || 1) + 1;
this.pageParam.set(next);
this.loadingMore.set(true);
this.armLoadingWatchdog();
this.unified.setPage(next);
}