From a588b444c20e477a3419d3e38d82860309868946 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 10:33:45 -0400 Subject: [PATCH] feat(twitch): rechargement de l'embed au premier geste (autoplay) - /watch + /shorts : le player Twitch refuse de demarrer sans geste utilisateur (anti-bot) et l'iframe etant cross-origin on ne peut pas cliquer dedans. On ecoute document:pointerdown (+ le keydown existant) et on ajoute &r=1 a l'URL de l'embed de la video courante : l'iframe se recharge dans le contexte d'activation utilisateur. - Une seule fois par video (twitchNudgedFor) : pas de boucle de rechargement, les videos suivantes sont re-armees automatiquement. - Verifie sur l'instance de test : 6/6 (URL initiale sans r=1, r=1 apres le geste, aucune recharge au 2e geste) sur /watch et /shorts. --- .../shorts/watch-short.component.ts | 31 ++++++++++++++-- src/components/watch/watch.component.ts | 36 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 4c2c84f..97574cf 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -364,6 +364,28 @@ export class WatchShortComponent implements OnDestroy { return this.exhaustedQueries.size < list.length; } + /** + * Nudge Twitch : le player Twitch refuse de démarrer sans geste utilisateur + * (anti-bot) et on ne peut pas cliquer dans une iframe cross-origin. On + * recharge donc l'embed au premier geste posé SUR LA PAGE (pointerdown / + * touche — un clic dans l'iframe ne nous parvient pas), une seule fois par + * short : `&r=1` n'est ajouté que si ce short est déjà « nudgé ». + */ + private twitchNudgedFor = signal(null); + + private nudgeTwitch(): void { + const v = this.current(); + if (!v || this.providerOf(v) !== 'twitch' || !this.embedUrl()) return; + const key = String(v.videoId || ''); + if (!key || this.twitchNudgedFor() === key) return; + this.twitchNudgedFor.set(key); + } + + @HostListener('document:pointerdown') + onPageGesture(): void { + this.nudgeTwitch(); + } + readonly embedUrl = computed(() => { const v = this.current(); if (!v) return null; @@ -399,6 +421,8 @@ export class WatchShortComponent implements OnDestroy { if (p === 'twitch' && v.videoId) { 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). + const nudge = this.twitchNudgedFor() === String(v.videoId) ? '&r=1' : ''; // Les Shorts Twitch = clips uniquement (jamais de live/channel). const clipId = (v as any).kind === 'clip' ? v.videoId : null; if (clipId) { @@ -406,7 +430,7 @@ export class WatchShortComponent implements OnDestroy { // n'expose aucune API de volume — muet est la seule combinaison // qui démarre sans geste. Le son reste disponible via « Ouvrir // chez le fournisseur » / Watch. - const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=true`; + const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=true${nudge}`; return this.sanitizer.bypassSecurityTrustResourceUrl(u); } // Repli : player channel/VOD (ne devrait pas arriver, les lives @@ -414,8 +438,8 @@ export class WatchShortComponent implements OnDestroy { const base = 'https://player.twitch.tv/'; const chan = !/^\d+$/.test(String(v.videoId)) ? v.videoId : null; const qs = chan - ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true` - : `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true`; + ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true${nudge}` + : `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true${nudge}`; return this.sanitizer.bypassSecurityTrustResourceUrl(base + qs); } if (p === 'peertube' && v.videoId) { @@ -1915,6 +1939,7 @@ export class WatchShortComponent implements OnDestroy { @HostListener('document:keydown', ['$event']) onKey(ev: KeyboardEvent) { + this.nudgeTwitch(); // 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.ts b/src/components/watch/watch.component.ts index 5e6039d..e144be8 100644 --- a/src/components/watch/watch.component.ts +++ b/src/components/watch/watch.component.ts @@ -398,6 +398,33 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // Backend-provided embed URL for providers that need it (e.g., Rumble) private rumbleEmbed = signal(null); + /** + * Nudge Twitch : le player Twitch refuse de démarrer sans geste utilisateur + * (anti-bot) et on ne peut pas cliquer dans une iframe cross-origin. On + * recharge donc l'embed au premier geste posé SUR LA PAGE (pointerdown / + * touche — un clic dans l'iframe ne nous parvient pas), ce qui se fait dans + * le contexte d'activation utilisateur. Une seule fois par vidéo (`&r=1` + * n'est ajouté que si la vidéo courante est déjà « nudgée »). + */ + private twitchNudgedFor = signal(null); + + /** Identité de l'embed Twitch affiché (change à chaque vidéo). */ + private twitchKey(): string { + return [this.videoId(), this.twitchChannel(), this.twitchClip()].map((v) => v || '').join('|'); + } + + private nudgeTwitch(): void { + if (this.provider() !== 'twitch' || !this.embedUrl()) return; + const key = this.twitchKey(); + if (!key || this.twitchNudgedFor() === key) return; + this.twitchNudgedFor.set(key); + } + + @HostListener('document:pointerdown') + onPageGesture(): void { + this.nudgeTwitch(); + } + embedUrl = computed(() => { const id = this.videoId(); const p = this.provider(); @@ -426,13 +453,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // 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('&'); + // `&r=1` = cette vidéo a déjà subi le nudge (voir twitchNudgedFor). + const nudge = this.twitchNudgedFor() === this.twitchKey() ? '&r=1' : ''; // Clips : player dédié clips.twitch.tv (le player VOD rejette les slugs). const clipId = clip || (/^\d+$/.test(String(id || '')) ? null : ((this.video() as any)?.kind === 'clip' ? id : null)); if (clipId) { // autoplay+muet, comme les autres fournisseurs de la page (YouTube // mute=1, Dailymotion mute=1, Odysee muted=1) : le lecteur démarre // tout seul, le son se réactive via les commandes du player. - const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=true`; + const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=true${nudge}`; return this.sanitizer.bypassSecurityTrustResourceUrl(u); } const base = 'https://player.twitch.tv/'; @@ -442,8 +471,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // Les VOD acceptent time=1h2m3s (seek au chargement, ex. transcript). const timeParam = !chan && start > 0 ? `&time=${encodeURIComponent(this.formatTwitchTime(start))}` : ''; const qs = chan - ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true` - : `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=true&muted=true${timeParam}`; + ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true${nudge}` + : `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=true&muted=true${timeParam}${nudge}`; const u = base + qs; return this.sanitizer.bypassSecurityTrustResourceUrl(u); } @@ -500,6 +529,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit { @HostListener('document:keydown', ['$event']) onGlobalKey(ev: KeyboardEvent): void { + this.nudgeTwitch(); try { const target = ev.target as HTMLElement | null; if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;