feat(twitch): rechargement de l'embed au premier geste (autoplay)
CI / build-and-test (push) Canceled after 0s
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:
@@ -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(); }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user