From 059481b9ead55b23f532938133ae75464248c768 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 12:08:03 -0400 Subject: [PATCH] feat(twitch): l'embed ne se charge qu'apres un geste (autoplay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Methode « touch listener b4 loading the iframe » : l'URL de l'embed Twitch n'est produite qu'apres un premier geste — navigator.userActivation. hasBeenActive a l'arrivee (clic sur un lien = fenetre d'activation de 5 s), sinon pointerdown / keydown / wheel. Le premier chargement de l'iframe tombe donc dans un contexte d'activation utilisateur. - Invite « Touchez pour charger la vidéo » sur /watch et /shorts pendant l'attente (sinon spinner infini sur /watch, cadre noir sur /shorts). - /shorts : pas d'avance auto tant que l'embed n'est pas charge — le flux ne switchait plus d'une video a l'autre sans jamais jouer. - Le nudge (recharge &r=1 au premier geste) est conserve pour les videos montees sans geste (avance auto, arrivee avec activation deja consommee). - Verifs instance locale : 8/8 (arrivee a froid : invite + montage au geste) et 6/6 (arrivee avec activation : montage immediat + recharge unique). --- .../shorts/watch-short.component.html | 9 +++++ .../shorts/watch-short.component.ts | 35 +++++++++++++++- src/components/watch/watch.component.html | 9 +++++ src/components/watch/watch.component.ts | 40 +++++++++++++++---- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index d12aeff..5d1c163 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -33,6 +33,15 @@ + } @else if (embedWaiting()) { + +
+ + Touchez pour charger la vidéo +
} @else if (loading()) {
diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 97574cf..d010927 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -373,6 +373,14 @@ export class WatchShortComponent implements OnDestroy { */ private twitchNudgedFor = signal(null); + /** + * L'embed Twitch ne se charge qu'APRÈS un geste (méthode « touch listener + * b4 loading the iframe ») : le premier chargement tombe alors dans le + * contexte d'activation utilisateur. `hasBeenActive` = l'utilisateur a déjà + * interagi avec la page (clic sur un lien, etc.) → on charge tout de suite. + */ + private twitchCanLoad = signal(!!(typeof navigator !== 'undefined' && (navigator as any).userActivation?.hasBeenActive)); + private nudgeTwitch(): void { const v = this.current(); if (!v || this.providerOf(v) !== 'twitch' || !this.embedUrl()) return; @@ -381,9 +389,26 @@ export class WatchShortComponent implements OnDestroy { this.twitchNudgedFor.set(key); } + /** Tout geste : autorise le chargement de l'embed Twitch puis le nudge. */ + private onUserGesture(): void { + if (!this.twitchCanLoad()) this.twitchCanLoad.set(true); + this.nudgeTwitch(); + } + + /** Vrai tant que l'embed Twitch attend un premier geste (affichage). */ + readonly embedWaiting = computed(() => { + const v = this.current(); + return !!v && this.providerOf(v) === 'twitch' && !this.twitchCanLoad(); + }); + @HostListener('document:pointerdown') onPageGesture(): void { - this.nudgeTwitch(); + this.onUserGesture(); + } + + @HostListener('document:wheel') + onPageWheel(): void { + this.onUserGesture(); } readonly embedUrl = computed(() => { @@ -419,6 +444,9 @@ export class WatchShortComponent implements OnDestroy { return this.sanitizer.bypassSecurityTrustResourceUrl(u); } if (p === 'twitch' && v.videoId) { + // Pas encore de geste : on ne charge PAS l'embed (le premier chargement + // doit tomber dans un contexte d'activation utilisateur). + if (!this.twitchCanLoad()) return null; const parentsSet = new Set([host, 'localhost', '127.0.0.1']); const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&'); // `&r=1` = ce short a déjà subi le nudge (voir twitchNudgedFor). @@ -777,6 +805,9 @@ export class WatchShortComponent implements OnDestroy { } catch {} this.autoAdvanceKey = ''; if (!v?.videoId) return; + // Embed Twitch en attente d'un geste : il n'y a rien à l'écran, inutile de + // faire défiler le flux (sinon les videos « switchent » sans jamais jouer). + if (this.providerOf(v) === 'twitch' && !this.twitchCanLoad()) return; const known = Number((v as Video).duration || 0); const secs = (Number.isFinite(known) && known > 0) ? known : this.estimatedDuration(v); if (!Number.isFinite(secs) || secs <= 0) return; @@ -1939,7 +1970,7 @@ export class WatchShortComponent implements OnDestroy { @HostListener('document:keydown', ['$event']) onKey(ev: KeyboardEvent) { - this.nudgeTwitch(); + this.onUserGesture(); // Menu FAB ouvert : ↑↓ doivent défiler PANNEAU, pas changer de short. if (this.items().length === 0 || this.menuOpen()) return; if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); } diff --git a/src/components/watch/watch.component.html b/src/components/watch/watch.component.html index 1ba1ef9..450b385 100644 --- a/src/components/watch/watch.component.html +++ b/src/components/watch/watch.component.html @@ -92,6 +92,15 @@ referrerpolicy="origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share" allowfullscreen title="Lecteur vidéo"> + } @else if (twitchWaiting()) { + +
+ + Touchez la page pour charger la vidéo +
} @else if (videoSource()) { (null); + /** + * L'embed Twitch ne se charge qu'APRÈS un geste (méthode « touch listener + * b4 loading the iframe ») : le premier chargement tombe alors dans le + * contexte d'activation utilisateur. `hasBeenActive` = l'utilisateur a déjà + * interagi avec la page (ex. clic sur un lien pour y arriver) → on charge + * tout de suite (la fenêtre d'activation de 5 s est encore ouverte). + */ + private twitchCanLoad = signal(!!(typeof navigator !== 'undefined' && (navigator as any).userActivation?.hasBeenActive)); + /** Identité de l'embed Twitch affiché (change à chaque vidéo). */ private twitchKey(): string { return [this.videoId(), this.twitchChannel(), this.twitchClip()].map((v) => v || '').join('|'); @@ -420,9 +429,23 @@ export class WatchComponent implements OnDestroy, AfterViewInit { this.twitchNudgedFor.set(key); } + /** Tout geste : autorise le chargement de l'embed Twitch puis le nudge. */ + private onUserGesture(): void { + if (!this.twitchCanLoad()) this.twitchCanLoad.set(true); + this.nudgeTwitch(); + } + + /** Vrai tant que l'embed Twitch attend un premier geste (affichage). */ + readonly twitchWaiting = computed(() => this.provider() === 'twitch' && !this.twitchCanLoad()); + @HostListener('document:pointerdown') onPageGesture(): void { - this.nudgeTwitch(); + this.onUserGesture(); + } + + @HostListener('document:wheel') + onPageWheel(): void { + this.onUserGesture(); } embedUrl = computed(() => { @@ -450,6 +473,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit { return this.sanitizer.bypassSecurityTrustResourceUrl(u); } if (p === 'twitch') { + // Pas encore de geste : on ne charge PAS l'embed (le premier chargement + // doit tomber dans un contexte d'activation utilisateur). + if (!this.twitchCanLoad()) return null; // Channel, clip ou VOD ; Twitch exige 'parent' = domaine d'embed. const parentsSet = new Set([host, 'localhost', '127.0.0.1']); const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&'); @@ -529,7 +555,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit { @HostListener('document:keydown', ['$event']) onGlobalKey(ev: KeyboardEvent): void { - this.nudgeTwitch(); + this.onUserGesture(); try { const target = ev.target as HTMLElement | null; if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;