fix(search): loading par frame sur les cartes, plus de cadres vides
CI / build-and-test (push) Successful in 14m8s
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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user