feat(twitch): l'embed ne se charge qu'apres un geste (autoplay)
CI / build-and-test (push) Canceled after 10m52s
CI / build-and-test (push) Canceled after 10m52s
- 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).
This commit is contained in:
@@ -33,6 +33,15 @@
|
|||||||
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" frameborder="0" playsinline
|
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" frameborder="0" playsinline
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||||
allowfullscreen referrerpolicy="origin"></iframe>
|
allowfullscreen referrerpolicy="origin"></iframe>
|
||||||
|
} @else if (embedWaiting()) {
|
||||||
|
<!-- Embed Twitch en attente d'un geste : le premier chargement de
|
||||||
|
l'iframe doit tomber dans un contexte d'activation utilisateur
|
||||||
|
(méthode « listener b4 loading »). N'importe quel geste sur la
|
||||||
|
page charge la vidéo. -->
|
||||||
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black text-slate-300" role="status">
|
||||||
|
<svg class="h-12 w-12 text-white/70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<span class="text-sm">Touchez pour charger la vidéo</span>
|
||||||
|
</div>
|
||||||
} @else if (loading()) {
|
} @else if (loading()) {
|
||||||
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-black text-slate-400 text-sm">
|
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-black text-slate-400 text-sm">
|
||||||
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
|
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -373,6 +373,14 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
*/
|
*/
|
||||||
private twitchNudgedFor = signal<string | null>(null);
|
private twitchNudgedFor = signal<string | null>(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 {
|
private nudgeTwitch(): void {
|
||||||
const v = this.current();
|
const v = this.current();
|
||||||
if (!v || this.providerOf(v) !== 'twitch' || !this.embedUrl()) return;
|
if (!v || this.providerOf(v) !== 'twitch' || !this.embedUrl()) return;
|
||||||
@@ -381,9 +389,26 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.twitchNudgedFor.set(key);
|
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')
|
@HostListener('document:pointerdown')
|
||||||
onPageGesture(): void {
|
onPageGesture(): void {
|
||||||
this.nudgeTwitch();
|
this.onUserGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:wheel')
|
||||||
|
onPageWheel(): void {
|
||||||
|
this.onUserGesture();
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
|
readonly embedUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
@@ -419,6 +444,9 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
if (p === 'twitch' && v.videoId) {
|
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<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).
|
// `&r=1` = ce short a déjà subi le nudge (voir twitchNudgedFor).
|
||||||
@@ -777,6 +805,9 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
} catch {}
|
} catch {}
|
||||||
this.autoAdvanceKey = '';
|
this.autoAdvanceKey = '';
|
||||||
if (!v?.videoId) return;
|
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 known = Number((v as Video).duration || 0);
|
||||||
const secs = (Number.isFinite(known) && known > 0) ? known : this.estimatedDuration(v);
|
const secs = (Number.isFinite(known) && known > 0) ? known : this.estimatedDuration(v);
|
||||||
if (!Number.isFinite(secs) || secs <= 0) return;
|
if (!Number.isFinite(secs) || secs <= 0) return;
|
||||||
@@ -1939,7 +1970,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
onKey(ev: KeyboardEvent) {
|
onKey(ev: KeyboardEvent) {
|
||||||
this.nudgeTwitch();
|
this.onUserGesture();
|
||||||
// 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(); }
|
||||||
|
|||||||
@@ -92,6 +92,15 @@
|
|||||||
referrerpolicy="origin"
|
referrerpolicy="origin"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||||
allowfullscreen title="Lecteur vidéo"></iframe>
|
allowfullscreen title="Lecteur vidéo"></iframe>
|
||||||
|
} @else if (twitchWaiting()) {
|
||||||
|
<!-- Embed Twitch en attente d'un geste : le premier chargement de
|
||||||
|
l'iframe doit tomber dans un contexte d'activation utilisateur
|
||||||
|
(méthode « listener b4 loading »). Tout clic/ touche / molette
|
||||||
|
sur la page déclenche le chargement. -->
|
||||||
|
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-black text-slate-300" role="status">
|
||||||
|
<svg class="h-12 w-12 text-white/70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<span class="text-sm">Touchez la page pour charger la vidéo</span>
|
||||||
|
</div>
|
||||||
} @else if (videoSource()) {
|
} @else if (videoSource()) {
|
||||||
<app-video-player
|
<app-video-player
|
||||||
[videoSource]="videoSource()"
|
[videoSource]="videoSource()"
|
||||||
|
|||||||
@@ -400,14 +400,23 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Nudge Twitch : le player Twitch refuse de démarrer sans geste utilisateur
|
* 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
|
* (anti-bot « browser interaction score ») et on ne peut pas cliquer dans une
|
||||||
* recharge donc l'embed au premier geste posé SUR LA PAGE (pointerdown /
|
* iframe cross-origin. On recharge donc l'embed au premier geste posé SUR LA
|
||||||
* touche — un clic dans l'iframe ne nous parvient pas), ce qui se fait dans
|
* PAGE (pointerdown / touche / molette — un clic dans l'iframe ne nous
|
||||||
* le contexte d'activation utilisateur. Une seule fois par vidéo (`&r=1`
|
* parvient pas), une seule fois par vidéo (`&r=1` ajouté seulement si la
|
||||||
* n'est ajouté que si la vidéo courante est déjà « nudgée »).
|
* vidéo courante est déjà « nudgée »).
|
||||||
*/
|
*/
|
||||||
private twitchNudgedFor = signal<string | null>(null);
|
private twitchNudgedFor = signal<string | null>(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). */
|
/** Identité de l'embed Twitch affiché (change à chaque vidéo). */
|
||||||
private twitchKey(): string {
|
private twitchKey(): string {
|
||||||
return [this.videoId(), this.twitchChannel(), this.twitchClip()].map((v) => v || '').join('|');
|
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);
|
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')
|
@HostListener('document:pointerdown')
|
||||||
onPageGesture(): void {
|
onPageGesture(): void {
|
||||||
this.nudgeTwitch();
|
this.onUserGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:wheel')
|
||||||
|
onPageWheel(): void {
|
||||||
|
this.onUserGesture();
|
||||||
}
|
}
|
||||||
|
|
||||||
embedUrl = computed<SafeResourceUrl | null>(() => {
|
embedUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
@@ -450,6 +473,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
if (p === 'twitch') {
|
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.
|
// 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('&');
|
||||||
@@ -529,7 +555,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();
|
this.onUserGesture();
|
||||||
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