-
![]()
+
+
+
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);
}