From bb2cd2f780680fb7a6cb72a96da18e3d9d77b200 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 3 Oct 2026 11:42:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(shorts):=20pr=C3=A9chauffage=20en=20arri?= =?UTF-8?q?=C3=A8re-plan=20du=20flux=20=C2=AB=20Pour=20toi=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cliquer sur « Pour toi » lançait un fan-out d'environ 22 appels (4 sujets × 5 fournisseurs + Twitch) et attendait le plus lent : 4 à 8 s mesuré, 13 s à froid. - Dès la première vidéo affichée, le lot est préparé EN ARRIÈRE-PLAN (`warmForYou`) : mêmes tuyaux (`topics$()` puis `forYouFeed$()`), aucune écriture de signal — l'écran ne bouge pas. Réchauffage périodique toutes les 5 min (onglet visible), lot réputé frais pendant 5 min. - `toggleForYou` consomme le lot : prêt → application immédiate ; en cours → le flux partagé (shareReplay) est rejoint au lieu de déclencher un second fan-out ; périmé ou absent → chemin réseau comme avant. - Refactor : `loadForYou` = `topics$()` + `fetchForYou`, le fan-out devient `forYouFeed$()` (pur, retourne le lot) et un seul `applyForYouFeed()` écrit les signaux, `seenIds` compris. - Journal `[forYou]` en console : préchauffage lancé, lot préparé, branche prise au clic — pour diagnostiquer un clic lent sans rejouer la scénario. Mesures (Playwright, localhost:4200) : clic avec lot prêt 34-74 ms (4,3 à 7,6 s avant), clic pendant la préparation 1,3-1,8 s avec UN seul fan-out, UI intacte pendant la préparation. Harnais : $LOCALAPPDATA/Temp/pw/foryou-warm-check.mjs (7 checks). --- .../shorts/watch-short.component.ts | 194 ++++++++++++++---- 1 file changed, 149 insertions(+), 45 deletions(-) diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 79f48f6..f71cf96 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { firstValueFrom, forkJoin, from, map, of } from 'rxjs'; -import { catchError, switchMap } from 'rxjs/operators'; +import { firstValueFrom, forkJoin, from, map, of, Observable } from 'rxjs'; +import { catchError, shareReplay, switchMap } from 'rxjs/operators'; import { YoutubeApiService } from '../../services/youtube-api.service'; import { InstanceService, Provider } from '../../services/instance.service'; import { HistoryService } from '../../services/history.service'; @@ -43,6 +43,13 @@ const LS_SHORTS_LANG_KEY = 'newtube.shorts.langFilter'; /** Clé de persistance de la lecture auto (activée par défaut). */ const LS_SHORTS_AUTONEXT_KEY = 'newtube.shorts.autoNext'; +/** Préchauffage « Pour toi » : fraîcheur d'un lot préparé en arrière-plan. */ +const FORYOU_PREFETCH_TTL_MS = 5 * 60_000; +/** ponytail : cadence ≤ TTL ⇒ un tour peut sauter (cycle ~5-10 min), largement + * suffisant pour servir un lot frais ; passer à un réchauffage serveur (cron + + * search_cache) si le coût client devient gênant. */ +const FORYOU_PREFETCH_EVERY_MS = 5 * 60_000; + type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string }; /** @@ -124,6 +131,12 @@ export class WatchShortComponent implements OnDestroy { forYou = signal(false); topics = signal([]); topicsLoading = signal(false); + /** Lot "Pour toi" préparé en arrière-plan (aucune écriture d'UI). */ + private forYouWarm: { at: number; items: Video[] } | null = null; + /** Lot en cours de préparation : un clic l'attend au lieu d'en lancer un 2e. */ + private forYouWarm$: Observable | null = null; + private forYouWarmStarted = false; + private forYouWarmTimer: ReturnType | null = null; // ---- Expérience façon YouTube Shorts ---- /** Cadre 9:16 (plein écran + focus). */ @@ -724,6 +737,23 @@ export class WatchShortComponent implements OnDestroy { this.fetchNextPage(false); } }); + // Préchauffage du flux "Pour toi" : dès que la PREMIÈRE vidéo est affichée + // (sans attendre la pagination, qui retarderait le lot au-delà du clic), + // on prépare ce lot EN ARRIÈRE-PLAN (sans toucher à l'UI) puis on le refait + // périodiquement — le clic sur "Pour toi" le consomme au lieu d'attendre le + // fan-out multi-fournisseurs (~4-8 s, 22 appels). + effect(() => { + const ready = !this.loading() && this.items().length > 0; + if (!ready || this.forYouWarmStarted) return; + this.forYouWarmStarted = true; + console.log('[forYou] préchauffage lancé'); + try { + this.warmForYou(); + this.forYouWarmTimer = setInterval(() => { + try { if (typeof document === 'undefined' || !document.hidden) this.warmForYou(); } catch {} + }, FORYOU_PREFETCH_EVERY_MS); + } catch {} + }); // Hydrate abonnements (état du bouton S'abonner) une fois. try { if (this.subs.items().length === 0) this.subs.list().subscribe(); @@ -820,6 +850,8 @@ export class WatchShortComponent implements OnDestroy { try { if (this.passthroughTimer) clearTimeout(this.passthroughTimer); } catch {} this.passthroughTimer = null; if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} } + try { if (this.forYouWarmTimer) clearInterval(this.forYouWarmTimer); } catch {} + this.forYouWarmTimer = null; } /** @@ -1404,9 +1436,62 @@ export class WatchShortComponent implements OnDestroy { return; } this.forYou.set(true); + // Lot préparé en arrière-plan : consommé au lieu de relancer le fan-out + // (~4-8 s). Un lot déjà en cours se rejoint (partagé) ; un lot périmé + // retombe sur le chargement réseau normal. + const inflight = this.forYouWarm$; + if (inflight) { + console.log('[forYou] clic : lot en préparation, on attend la fin'); + this.consumeForYou(inflight); + return; + } + const warm = this.forYouWarm; + if (warm && Date.now() - warm.at < FORYOU_PREFETCH_TTL_MS) { + console.log(`[forYou] clic : lot prêt (${warm.items.length} items, âge ${Math.round((Date.now() - warm.at) / 1000)} s)`); + this.applyForYouFeed(warm.items); + return; + } + console.log('[forYou] clic : aucun lot frais, chargement réseau'); this.loadForYou(); } + /** S'abonne au lot partagé : valeur rejouée si prêt, sinon fin du fan-out. */ + private consumeForYou(shared: Observable): void { + this.loading.set(true); + this.error.set(null); + this.items.set([]); + this.index.set(0); + this.nextCursor.set(null); + shared.subscribe({ + next: (items) => this.applyForYouFeed(items || []), + error: () => { this.afterNoResults(); this.loading.set(false); }, + }); + } + + /** + * Prépare le lot "Pour toi" EN ARRIÈRE-PLAN : mêmes tuyaux que le chargement + * (sujets puis fan-out), mais AUCUNE écriture de signal — l'écran ne bouge + * pas. Le flux est partagé : un clic pendant la préparation rejoint ce même + * appel au lieu d'en déclencher un deuxième. + */ + private warmForYou(): void { + if (this.forYouWarm$) return; // déjà en vol + if (this.forYouWarm && Date.now() - this.forYouWarm.at < FORYOU_PREFETCH_TTL_MS) return; // déjà frais + const shared = this.topics$().pipe( + switchMap((topics) => this.forYouFeed$(topics)), + shareReplay({ bufferSize: 1, refCount: false }), + ); + this.forYouWarm$ = shared; + shared.subscribe({ + next: (items) => { + console.log(`[forYou] lot préparé en arrière-plan : ${items?.length ?? 0} items`); + if (items?.length) this.forYouWarm = { at: Date.now(), items }; + }, + error: () => console.log('[forYou] préchauffage en échec'), + complete: () => { this.forYouWarm$ = null; }, + }); + } + private loadForYou(): void { this.loading.set(true); this.error.set(null); @@ -1415,6 +1500,26 @@ export class WatchShortComponent implements OnDestroy { this.nextCursor.set(null); this.seenIds = new Set(); this.topicsLoading.set(true); + this.topics$().subscribe({ + next: (topics) => { + this.topics.set(topics); + this.topicsLoading.set(false); + this.fetchForYou(topics); + }, + error: () => { + this.topicsLoading.set(false); + this.topics.set(['music', 'gaming', 'funny', 'sports']); + this.fetchForYou(this.topics()); + }, + }); + } + + /** + * Sujets "Pour toi" : hydratation (abos, groupes, historique, likes, + * téléchargements) puis extraction pondérée ; repli sur les sujets génériques + * si aucun signal. Partagé par le chargement et par le préchauffage. + */ + private topics$(): Observable { // Hydrate les abonnements/groupes (signaux) s'ils ne sont pas chargés. try { if (this.subs.items().length === 0) this.subs.list().subscribe(); } catch {} try { this.subs.listGroups().subscribe(); } catch {} @@ -1423,23 +1528,14 @@ export class WatchShortComponent implements OnDestroy { const watch$ = loggedIn ? this.history.getWatchHistory(30).pipe(catchError(() => of([]))) : of([]); const likes$ = loggedIn ? this.likes.list(100).pipe(catchError(() => of([]))) : of([]); const dl$ = loggedIn ? this.downloads.listJobs({ limit: 50 }).pipe(catchError(() => of({ items: [] }))) : of({ items: [] }); - forkJoin([search$, watch$, likes$, dl$]).subscribe({ - next: ([searches, watches, liked, jobs]: any[]) => { + const fallback = ['music', 'gaming', 'funny', 'sports']; + return forkJoin([search$, watch$, likes$, dl$]).pipe( + map(([searches, watches, liked, jobs]: any[]) => { const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []); - this.topics.set(topics); - this.topicsLoading.set(false); - if (!topics.length) { - // Aucun signal : repli sur des sujets génériques populaires. - this.topics.set(['music', 'gaming', 'funny', 'sports']); - } - this.fetchForYou(this.topics()); - }, - error: () => { - this.topicsLoading.set(false); - this.topics.set(['music', 'gaming', 'funny', 'sports']); - this.fetchForYou(this.topics()); - } - }); + return topics.length ? topics : fallback; + }), + catchError(() => of(fallback)), + ); } /** Extrait des sujets pondérés (abos ×3, recherches ×2, reste ×1). */ @@ -1522,13 +1618,14 @@ export class WatchShortComponent implements OnDestroy { ); } - /** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */ - private fetchForYou(topics: string[]): void { const picks = topics.slice(0, 4); - if (!picks.length) { - this.afterNoResults(); - this.loading.set(false); - return; - } + /** + * Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. + * RETOURNE le lot sans écrire le moindre signal : le même tuyau sert le + * chargement « Pour toi » ET son préchauffage en arrière-plan. + */ + private forYouFeed$(topics: string[]): Observable { + const picks = topics.slice(0, 4); + if (!picks.length) return of([] as Video[]); const defs: Array<{ provider: Provider; call: any }> = []; const ready = (p: Provider) => { try { return this.instances.getProviderReadiness(p).ready; } catch { return true; } @@ -1545,13 +1642,9 @@ export class WatchShortComponent implements OnDestroy { defs.push({ provider: 'twitch', call: this.api.searchTwitchClipsPage(t).pipe(catchError(() => of({ items: [], nextCursor: null }))) }); } } - if (!defs.length) { - this.afterNoResults(); - this.loading.set(false); - return; - } - forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).subscribe({ - next: (pages: any[]) => { + if (!defs.length) return of([] as Video[]); + return forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).pipe( + map((pages: any[]) => { // Regroupe par provider puis entrelace (tourniquet) pour mixer les sources. const byProvider: Video[][] = defs.map((d, i) => { const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId); @@ -1575,24 +1668,35 @@ export class WatchShortComponent implements OnDestroy { if (!v) continue; const key = `${String((v as any).provider || '')}:${String(v.videoId)}`; if (this.seenIds.has(key)) continue; - this.seenIds.add(key); merged.push(v); added = true; if (merged.length >= 80) break; } } - this.items.set(merged); - this.index.set(0); - this.nextCursor.set(null); - // Pré-validation Rumble (retire les "Video not found"). - this.queueRumbleResolves(merged); - if (!merged.length) this.afterNoResults(); else this.error.set(null); - this.loading.set(false); - }, - error: () => { - this.afterNoResults(); - this.loading.set(false); - } + return merged; + }), + catchError(() => of([] as Video[])), + ); + } + + /** Le seul endroit qui écrit les signaux d'un lot « Pour toi ». */ + private applyForYouFeed(merged: Video[]): void { + this.seenIds = new Set(); + for (const v of merged) this.seenIds.add(`${String((v as any).provider || '')}:${String(v.videoId)}`); + this.items.set(merged); + this.index.set(0); + this.nextCursor.set(null); + // Pré-validation Rumble (retire les "Video not found"). + this.queueRumbleResolves(merged); + if (!merged.length) this.afterNoResults(); else this.error.set(null); + this.loading.set(false); + } + + /** Chargement réseau du flux « Pour toi » (quand aucun lot n'est prêt). */ + private fetchForYou(topics: string[]): void { + this.forYouFeed$(topics).subscribe({ + next: (merged) => this.applyForYouFeed(merged), + error: () => { this.afterNoResults(); this.loading.set(false); }, }); }