diff --git a/src/app/shared/components/search-result-grid/search-result-grid.component.html b/src/app/shared/components/search-result-grid/search-result-grid.component.html index e196c05..6638f74 100644 --- a/src/app/shared/components/search-result-grid/search-result-grid.component.html +++ b/src/app/shared/components/search-result-grid/search-result-grid.component.html @@ -1,9 +1,21 @@ -
- - - - +
+ +
+ + Chargement des resultats… + + + +
+ +
+ + + + +
diff --git a/src/app/shared/components/search-result-grid/search-result-grid.component.ts b/src/app/shared/components/search-result-grid/search-result-grid.component.ts index df5f359..c79e450 100644 --- a/src/app/shared/components/search-result-grid/search-result-grid.component.ts +++ b/src/app/shared/components/search-result-grid/search-result-grid.component.ts @@ -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); diff --git a/src/app/shared/components/video-card-skeleton/video-card-skeleton.component.html b/src/app/shared/components/video-card-skeleton/video-card-skeleton.component.html index 97bfe75..f251b24 100644 --- a/src/app/shared/components/video-card-skeleton/video-card-skeleton.component.html +++ b/src/app/shared/components/video-card-skeleton/video-card-skeleton.component.html @@ -1,7 +1,15 @@ -
-
+
+
+
+ +
+
+
+
+
+
diff --git a/src/app/shared/components/video-card/video-card.component.html b/src/app/shared/components/video-card/video-card.component.html index 459c1a1..0983b9f 100644 --- a/src/app/shared/components/video-card/video-card.component.html +++ b/src/app/shared/components/video-card/video-card.component.html @@ -1,7 +1,33 @@
- + + + Chargement de l'aperçu… + + + + +
+ + Aperçu indisponible + +
+
diff --git a/src/app/shared/components/video-card/video-card.component.ts b/src/app/shared/components/video-card/video-card.component.ts index 366811d..8064884 100644 --- a/src/app/shared/components/video-card/video-card.component.ts +++ b/src/app/shared/components/video-card/video-card.component.ts @@ -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(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 }; diff --git a/src/components/search/search.component.ts b/src/components/search/search.component.ts index 7cfe14a..e7d4064 100644 --- a/src/components/search/search.component.ts +++ b/src/components/search/search.component.ts @@ -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 | 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); }