fix(shorts): hover-chrome, swipe mobile garanti, avance auto fiabilisee
CI / build-and-test (push) Successful in 15m2s

Chrome auto-hide au hover desktop (2,5 s). Capteur tactile mobile : swipe garanti (l'iframe mangeait les touchers), tap = play/pause YT ou 6 s d'acces direct au lecteur sinon. Fleche retour accueil mobile, cadre quasi plein ecran. Avance auto : handshake YT listening (les evenements player n'arrivaient jamais), onStateChange canonique, retry borne anti-prefetch. Docs 9.9.
This commit is contained in:
2026-09-30 12:21:15 -04:00
parent 8f100d2b78
commit 3e1980283a
4 changed files with 198 additions and 25 deletions
@@ -486,6 +486,8 @@ La table est **additive**. Le Phase 5.3 (LEFT JOIN) est le seul changement de le
- [x] Son et lecture par provider : son par défaut (YT/OD/DM/PT), clips Twitch en muet forcé (pas d'API de volume sur l'embed) ; Odysee démarre muet (pas de rectangle « tap to unmute ») et ne passe au son que sur clic explicite du bouton Son (le geste autorise l'autoplay sonore) ; nudge `playVideo` YouTube si l'autoplay a calé.
- [x] Avance auto en fin de vidéo tous providers : `playerState` YouTube (0 = fin → suivant, 2 = pause → minuteur désarmé, 1 = reprise → réarmé) + minuteur générique sur durée connue ou estimée (clip Twitch = 60 s, sinon plafond Short 90 s) + 2,5 s de grâce, ignoré si l'onglet est caché.
- [x] Anti-Forbidden Dailymotion : `allow_embed` demandé côté serveur → `embeddable` → `raw.allowEmbed`, le garde `isPlayable` existant exclut ces vidéos avant affichage (même garde que le chemin historique).
- [x] Chrome auto-hide au hover (desktop, 2,5 s d'immobilité) ; capteur tactile mobile (l'iframe mange les touchers) : swipe = navigation garantie, tap = play/pause YouTube (API iframe) ou 6 s d'accès direct au lecteur sinon (les boutons du fournisseur restent atteignables) ; flèche retour accueil en haut à gauche sur mobile ; cadre quasi plein écran (`--shorts-chrome: 5,5rem`).
- [x] Avance auto fiabilisée : handshake `listening` YouTube SANS LEQUEL aucun événement player n'arrive (progression + fin mortes), `onStateChange` canonique, réessais bornés quand `next()` est absorbé par un pré-chargement en cours.
---
+27 -2
View File
@@ -507,9 +507,10 @@ body {
}
}
@media (max-width: 767px) {
/* Mobile : cadre quasi plein écran (seuls les chips + marges sont réservés). */
/* Mobile : quasi plein écran (seule la rangée de chips est réservée :
la flèche retour et le chrome vivent EN OVERLAY dans le cadre). */
.shorts-frame {
--shorts-chrome: 8.5rem;
--shorts-chrome: 5.5rem;
}
}
@@ -547,6 +548,30 @@ body {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
/* --- Shorts : chrome auto-hide + capteur tactile ---------------------------- */
/* Desktop : le chrome (pastilles, boutons, rail, méta) s'efface après 2,5 s
d'immobilité de la souris et revient au moindre mouvement (façon YouTube).
Sur tactile, `mousemove` ne tire presque jamais : tout reste visible. */
.shorts-chrome {
transition: opacity 0.25s ease;
}
.shorts-chrome-hidden .shorts-chrome {
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.shorts-chrome {
transition: none;
}
}
/* Capteur tactile mobile : couche transparente AU-DESSUS de l'iframe (qui
mangerait les touchers) mais SOUS le chrome. `touch-action: none` donne le
geste à nos handlers (swipe = navigation, tap = play/pause ou accès
lecteur) au lieu de scroller/zoomer la page. */
.shorts-gesture {
touch-action: none;
}
/* --- Drawer / overlay plumbing -------------------------------------------- */
/* Applied to <body> while a full-screen drawer or modal owns the viewport.
Note: deliberately NOT `touch-action: none` — touch-action is intersected up
@@ -1,4 +1,6 @@
<div class="shorts-stage relative flex min-h-[calc(var(--app-vh,100dvh)-var(--header-offset,0px))] w-full flex-col items-center overflow-hidden bg-black" (click)="closeMenu()">
<div class="shorts-stage relative flex min-h-[calc(var(--app-vh,100dvh)-var(--header-offset,0px))] w-full flex-col items-center overflow-hidden bg-black"
[class.shorts-chrome-hidden]="!chromeVisible()"
(click)="closeMenu()" (mousemove)="pokeChrome()">
<!-- Fond d'ambiance : vignette du short courant, floutée (façon YouTube). -->
<div class="shorts-ambient" aria-hidden="true">
@if (current()?.thumbnail) {
@@ -6,8 +8,15 @@
}
</div>
<!-- Retour accueil (mobile plein écran : la vidéo occupe tout, pas de header visible) -->
<a routerLink="/"
class="absolute top-2 left-2 z-20 flex h-11 w-11 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80 sm:hidden"
title="Retour à l'accueil NewTube" aria-label="Retour à l'accueil NewTube" (click)="$event.stopPropagation()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 12H5m7-7l-7 7 7 7"/></svg>
</a>
<!-- Sélecteur : Pour toi + fournisseurs -->
<div class="scroll-rail no-scrollbar relative z-10 flex w-full max-w-full items-center justify-start gap-2 overflow-x-auto px-3 pt-3 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
<div class="scroll-rail no-scrollbar shorts-chrome relative z-10 flex w-full max-w-full items-center justify-start gap-2 overflow-x-auto px-3 pt-3 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
<button (click)="toggleForYou()"
[attr.aria-pressed]="forYou()"
class="chip shrink-0 px-3 py-2 rounded-full text-[13px] border transition-colors min-h-[44px]"
@@ -29,7 +38,7 @@
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
<!-- Flèche précédent (bureau, façon YouTube) -->
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
class="shorts-chrome chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
aria-label="Short précédent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 14l5-5 5 5H7z"/></svg>
</button>
@@ -55,8 +64,23 @@
}
@if (current(); as v) {
<!-- Capteur tactile (mobile uniquement) : l'iframe mange les
touchers, donc le swipe up/down passerait à la trappe. Cette
couche transparente capte swipe (navigue) et tap (play/pause
YouTube, ou accès direct au lecteur 6 s sinon). `touch-action`
via CSS : le navigateur ne scrolle/zoome pas, nos handlers
possèdent le geste. En mode passthrough, elle s'efface
(les boutons du fournisseur deviennent touchables). -->
<div class="shorts-gesture absolute inset-0 z-[1] sm:hidden"
[class.pointer-events-none]="passthrough()"
aria-hidden="true"></div>
@if (passthroughHint()) {
<div class="absolute left-1/2 top-14 z-20 -translate-x-1/2 whitespace-nowrap px-3 py-1.5 rounded-full bg-black/75 text-white text-xs" role="status">Commandes du lecteur accessibles</div>
}
<!-- Haut droite : plein écran, partage rapide, menu ⋯ -->
<div class="absolute top-2 right-2 flex items-center gap-1.5" (click)="$event.stopPropagation()">
<div class="shorts-chrome absolute top-2 right-2 z-10 flex items-center gap-1.5" (click)="$event.stopPropagation()">
<button type="button" (click)="toggleFullscreen()" class="flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" [title]="isFullscreen() ? 'Quitter le plein écran' : 'Plein écran'" aria-label="Plein écran">
@if (isFullscreen()) {
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>
@@ -86,7 +110,7 @@
</div>
<!-- Rail d'actions OVERLAY façon YouTube (dans le cadre, comme sur mobile natif) -->
<div class="absolute right-1.5 sm:right-2.5 bottom-32 sm:bottom-36 flex flex-col items-center gap-2 sm:gap-2.5" (click)="$event.stopPropagation()">
<div class="shorts-chrome absolute right-1.5 sm:right-2.5 bottom-32 sm:bottom-36 z-10 flex flex-col items-center gap-2 sm:gap-2.5" (click)="$event.stopPropagation()">
<div class="flex flex-col items-center">
<button type="button" (click)="toggleLike()" [disabled]="!isLoggedIn() || likeBusy()"
class="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75 disabled:opacity-40"
@@ -126,7 +150,7 @@
</div>
<!-- Bas : avatar + titre + méta + fraîcheur + S'abonner (safe-area incluse) -->
<div class="shorts-meta pointer-events-none absolute left-0 right-0 bottom-0 p-3 sm:p-4 text-white bg-gradient-to-t from-black/85 via-black/35 to-transparent">
<div class="shorts-meta shorts-chrome pointer-events-none absolute left-0 right-0 bottom-0 z-10 p-3 sm:p-4 text-white bg-gradient-to-t from-black/85 via-black/35 to-transparent">
<div class="flex items-end gap-2.5 text-sm" (click)="$event.stopPropagation()">
@if (v.uploaderAvatar) {
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-white/40" loading="lazy" />
@@ -180,7 +204,7 @@
<!-- Flèche suivant (bureau, façon YouTube) -->
<button type="button" (click)="next(); $event.stopPropagation()" [disabled]="!canNext()"
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
class="shorts-chrome chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
aria-label="Short suivant">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
</button>
+138 -16
View File
@@ -518,6 +518,7 @@ export class WatchShortComponent implements OnDestroy {
const i = this.index();
const list = this.items();
this.ytProgress.set(null);
this.ytPlaying.set(true);
this.menuOpen.set(false);
if (!v) return;
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
@@ -559,6 +560,10 @@ export class WatchShortComponent implements OnDestroy {
try { document.removeEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
try { if (this.autoAdvanceTimer) clearTimeout(this.autoAdvanceTimer); } catch {}
this.autoAdvanceTimer = null;
try { if (this.chromeIdleTimer) clearTimeout(this.chromeIdleTimer); } catch {}
this.chromeIdleTimer = null;
try { if (this.passthroughTimer) clearTimeout(this.passthroughTimer); } catch {}
this.passthroughTimer = null;
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
}
@@ -600,6 +605,15 @@ export class WatchShortComponent implements OnDestroy {
/** Timer d'avance auto en fin de vidéo (repli générique). */
private autoAdvanceTimer: any = null;
private autoAdvanceKey = '';
/** État de lecture YouTube (via `playerState`, pour le toggle tap). */
private ytPlaying = signal(true);
/** Chrome (boutons/méta) visible — auto-hide sur desktop au repos. */
readonly chromeVisible = signal(true);
private chromeIdleTimer: any = null;
/** Accès direct au lecteur (overlay effacé 6 s, tap non-YouTube). */
readonly passthrough = signal(false);
readonly passthroughHint = signal(false);
private passthroughTimer: any = null;
/**
* Durée effective pour l'avance auto quand la durée réelle est inconnue.
@@ -649,11 +663,43 @@ export class WatchShortComponent implements OnDestroy {
if (typeof document !== 'undefined' && document.hidden) return;
if (this.autoAdvanceKey !== key) return;
const cur = this.current();
if (this.index() === atIndex && cur && String(cur.videoId) === id) this.next();
if (this.index() === atIndex && cur && String(cur.videoId) === id) this.advanceWithRetry();
} catch {}
}, Math.min(600000, (secs + 2.5) * 1000));
}
/**
* Avance avec réessais bornés : `next()` peut être absorbé sans effet quand
* un pré-chargement est en cours (`busyMore`) ou que la page suivante n'est
* pas encore arrivée — sans réessai, la vidéo reste bloquée sur son écran
* de fin. 2 réessais à 2,5 s puis abandon (jamais de boucle).
*/
private advanceWithRetry(attempt = 0): void {
try {
const before = this.index();
if (!this.items().length || this.loading()) return;
this.next();
if (this.index() !== before) return;
if (attempt < 2) {
setTimeout(() => {
try {
if (this.index() === before && this.items().length > 0 && !this.loading()) {
this.advanceWithRetry(attempt + 1);
}
} catch {}
}, 2500);
}
} catch {}
}
/** Fin de lecture vue par un player (garde anti-double). */
private advanceFromPlayer(id: string): void {
const key = `end:${id}`;
if (this.autoAdvanceKey === key) return;
this.autoAdvanceKey = key;
this.advanceWithRetry();
}
/**
* Relance la lecture YouTube si l'autoplay a calé : commande `playVideo`
* via l'API iframe (`enablejsapi=1` déjà présent dans l'URL d'embed).
@@ -664,18 +710,30 @@ export class WatchShortComponent implements OnDestroy {
if (!v?.videoId || this.providerOf(v) !== 'youtube') return;
const id = String(v.videoId);
const atIndex = this.index();
// Handshake SANS LEQUEL le player YouTube n'émet RIEN (ni infoDelivery
// ni onStateChange) : c'est ce qui rendait l'avance auto et la barre de
// progression inopérantes. Envoyé tout de suite + après chargement.
this.postToPlayer({ event: 'listening' });
setTimeout(() => {
try {
const cur = this.current();
if (this.index() !== atIndex || !cur || String(cur.videoId) !== id) return;
const frame = this.frameRef()?.nativeElement;
const iframe = frame?.querySelector('iframe') as HTMLIFrameElement | null;
iframe?.contentWindow?.postMessage(JSON.stringify({ event: 'command', func: 'playVideo', args: '' }), '*');
this.postToPlayer({ event: 'listening' });
if (this.ytPlaying()) this.postToPlayer({ event: 'command', func: 'playVideo', args: '' });
} catch {}
}, 1500);
} catch {}
}
/** Post un message à l'iframe du short courant (API YouTube, sans effet sinon). */
private postToPlayer(msg: object): void {
try {
const frame = this.frameRef()?.nativeElement;
const iframe = frame?.querySelector('iframe') as HTMLIFrameElement | null;
iframe?.contentWindow?.postMessage(JSON.stringify(msg), '*');
} catch {}
}
/**
* File de résolution Rumble (concurrence bornée) : chaque id est vérifié
* via le backend (comme /watch). Succès => l'embed bascule sur le vrai
@@ -743,6 +801,55 @@ export class WatchShortComponent implements OnDestroy {
this.muted.update(m => !m);
}
/**
* Chrome auto-hide (desktop) : visible au survol / à l'interaction, masqué
* après 2,5 s d'immobilité — façon YouTube. Sur tactile, `mousemove` ne
* tire presque jamais : le chrome reste visible en permanence.
*/
pokeChrome(): void {
try {
this.chromeVisible.set(true);
if (this.chromeIdleTimer) clearTimeout(this.chromeIdleTimer);
this.chromeIdleTimer = setTimeout(() => {
try { this.chromeVisible.set(false); } catch {}
}, 2500);
} catch {}
}
/**
* Tap sur la vidéo (mobile : l'overlay capte le toucher, l'iframe ne le
* voit pas — voir `.shorts-gesture`) :
* - YouTube : play/pause via l'API iframe (état suivi par `playerState`) ;
* - autres (pas d'API) : 6 s d'accès DIRECT au lecteur — l'overlay
* s'efface et les boutons du fournisseur deviennent touchables, puis
* l'overlay revient seul. Discret et réversible.
*/
private onVideoTap(): void {
try {
const v = this.current();
if (!v) return;
this.pokeChrome();
if (this.providerOf(v) === 'youtube') {
const play = !this.ytPlaying();
this.postToPlayer({ event: 'command', func: play ? 'playVideo' : 'pauseVideo', args: '' });
this.ytPlaying.set(play);
return;
}
this.enterPassthrough();
} catch {}
}
private enterPassthrough(): void {
try {
this.passthrough.set(true);
this.passthroughHint.set(true);
if (this.passthroughTimer) clearTimeout(this.passthroughTimer);
this.passthroughTimer = setTimeout(() => {
try { this.passthrough.set(false); this.passthroughHint.set(false); } catch {}
}, 6000);
} catch {}
}
toggleFullscreen(): void {
try {
if (document.fullscreenElement) {
@@ -857,6 +964,26 @@ export class WatchShortComponent implements OnDestroy {
try { this.skipUnplayable(); } catch {}
return;
}
// Fin/début/pause canoniques (`onStateChange`, plus fiable que le temps :
// pas de marge). Requiert le handshake `listening` (voir nudgeYouTubePlay).
if (data.event === 'onStateChange') {
const st = Number(data.info);
const cur = this.current();
if (!cur || String(cur.videoId) !== String(v.videoId) || !Number.isFinite(st)) return;
const id = String(v.videoId);
if (st === 0) {
this.advanceFromPlayer(id);
} else if (st === 2) {
// Pause utilisateur : état + minuteur désarmé (on n'avance pas une
// vidéo mise en pause).
this.ytPlaying.set(false);
this.autoAdvanceKey = `paused:${id}`;
} else if (st === 1) {
this.ytPlaying.set(true);
if (this.autoAdvanceKey.startsWith('paused:')) this.scheduleAutoAdvance(v);
}
return;
}
if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') {
const ct = Number(data.info.currentTime);
let dur = Number(data.info.duration);
@@ -869,23 +996,21 @@ export class WatchShortComponent implements OnDestroy {
const id = String(v.videoId);
if (state === 0) {
// Fin certaine (plus fiable que le temps : pas de marge) → suivant.
const key = `end:${id}`;
if (this.autoAdvanceKey !== key) {
this.autoAdvanceKey = key;
this.next();
}
this.advanceFromPlayer(id);
return;
}
if (state === 2) {
// Pause utilisateur : on désarme le minuteur générique pour ne
// pas faire avancer une vidéo mise en pause.
this.ytPlaying.set(false);
this.autoAdvanceKey = `paused:${id}`;
return;
}
if (state === 1 && this.autoAdvanceKey.startsWith('paused:')) {
if (state === 1) {
// Reprise après pause : on réarme le minuteur (repli, la fin
// temps réel prime de toute façon).
this.scheduleAutoAdvance(v);
this.ytPlaying.set(true);
if (this.autoAdvanceKey.startsWith('paused:')) this.scheduleAutoAdvance(v);
return;
}
}
@@ -898,11 +1023,7 @@ export class WatchShortComponent implements OnDestroy {
if (ct >= dur - 0.75) {
const cur = this.current();
if (cur && String(cur.videoId) === String(v.videoId)) {
const key = `end:${String(v.videoId)}`;
if (this.autoAdvanceKey !== key) {
this.autoAdvanceKey = key;
this.next();
}
this.advanceFromPlayer(String(v.videoId));
}
}
}
@@ -1730,6 +1851,7 @@ export class WatchShortComponent implements OnDestroy {
const threshold = 50;
if (dy < -threshold) this.next();
else if (dy > threshold) this.prev();
else if (Math.abs(dy) < 12) this.onVideoTap();
this.lastSwipeY = null;
}
}