feat(twitch): rechargement de l'embed au premier geste (autoplay)
CI / build-and-test (push) Canceled after 0s

- /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.
This commit is contained in:
2026-10-01 10:33:45 -04:00
parent 4463023c84
commit a588b444c2
2 changed files with 61 additions and 6 deletions
+28 -3
View File
@@ -364,6 +364,28 @@ export class WatchShortComponent implements OnDestroy {
return this.exhaustedQueries.size < list.length; 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<string | null>(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<SafeResourceUrl | null>(() => { readonly embedUrl = computed<SafeResourceUrl | null>(() => {
const v = this.current(); const v = this.current();
if (!v) return null; if (!v) return null;
@@ -399,6 +421,8 @@ export class WatchShortComponent implements OnDestroy {
if (p === 'twitch' && v.videoId) { if (p === 'twitch' && v.videoId) {
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']); const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&'); 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). // Les Shorts Twitch = clips uniquement (jamais de live/channel).
const clipId = (v as any).kind === 'clip' ? v.videoId : null; const clipId = (v as any).kind === 'clip' ? v.videoId : null;
if (clipId) { if (clipId) {
@@ -406,7 +430,7 @@ export class WatchShortComponent implements OnDestroy {
// n'expose aucune API de volume — muet est la seule combinaison // n'expose aucune API de volume — muet est la seule combinaison
// qui démarre sans geste. Le son reste disponible via « Ouvrir // qui démarre sans geste. Le son reste disponible via « Ouvrir
// chez le fournisseur » / Watch. // 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); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
// Repli : player channel/VOD (ne devrait pas arriver, les lives // 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 base = 'https://player.twitch.tv/';
const chan = !/^\d+$/.test(String(v.videoId)) ? v.videoId : null; const chan = !/^\d+$/.test(String(v.videoId)) ? v.videoId : null;
const qs = chan const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true` ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true${nudge}`
: `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true`; : `?video=${encodeURIComponent(v.videoId)}&${parentParams}&autoplay=true&muted=true${nudge}`;
return this.sanitizer.bypassSecurityTrustResourceUrl(base + qs); return this.sanitizer.bypassSecurityTrustResourceUrl(base + qs);
} }
if (p === 'peertube' && v.videoId) { if (p === 'peertube' && v.videoId) {
@@ -1915,6 +1939,7 @@ export class WatchShortComponent implements OnDestroy {
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onKey(ev: KeyboardEvent) { onKey(ev: KeyboardEvent) {
this.nudgeTwitch();
// Menu FAB ouvert : ↑↓ doivent défiler PANNEAU, pas changer de short. // Menu FAB ouvert : ↑↓ doivent défiler PANNEAU, pas changer de short.
if (this.items().length === 0 || this.menuOpen()) return; if (this.items().length === 0 || this.menuOpen()) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); } if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
+33 -3
View File
@@ -398,6 +398,33 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Backend-provided embed URL for providers that need it (e.g., Rumble) // Backend-provided embed URL for providers that need it (e.g., Rumble)
private rumbleEmbed = signal<string | null>(null); private rumbleEmbed = signal<string | null>(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<string | null>(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<SafeResourceUrl | null>(() => { embedUrl = computed<SafeResourceUrl | null>(() => {
const id = this.videoId(); const id = this.videoId();
const p = this.provider(); const p = this.provider();
@@ -426,13 +453,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Channel, clip ou VOD ; Twitch exige 'parent' = domaine d'embed. // Channel, clip ou VOD ; Twitch exige 'parent' = domaine d'embed.
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']); const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&'); 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). // 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)); const clipId = clip || (/^\d+$/.test(String(id || '')) ? null : ((this.video() as any)?.kind === 'clip' ? id : null));
if (clipId) { if (clipId) {
// autoplay+muet, comme les autres fournisseurs de la page (YouTube // autoplay+muet, comme les autres fournisseurs de la page (YouTube
// mute=1, Dailymotion mute=1, Odysee muted=1) : le lecteur démarre // mute=1, Dailymotion mute=1, Odysee muted=1) : le lecteur démarre
// tout seul, le son se réactive via les commandes du player. // 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); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
const base = 'https://player.twitch.tv/'; 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). // Les VOD acceptent time=1h2m3s (seek au chargement, ex. transcript).
const timeParam = !chan && start > 0 ? `&time=${encodeURIComponent(this.formatTwitchTime(start))}` : ''; const timeParam = !chan && start > 0 ? `&time=${encodeURIComponent(this.formatTwitchTime(start))}` : '';
const qs = chan const qs = chan
? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true` ? `?channel=${encodeURIComponent(chan)}&${parentParams}&autoplay=true&muted=true${nudge}`
: `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=true&muted=true${timeParam}`; : `?video=${encodeURIComponent(id)}&${parentParams}&autoplay=true&muted=true${timeParam}${nudge}`;
const u = base + qs; const u = base + qs;
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
@@ -500,6 +529,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent): void { onGlobalKey(ev: KeyboardEvent): void {
this.nudgeTwitch();
try { try {
const target = ev.target as HTMLElement | null; const target = ev.target as HTMLElement | null;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return; if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;