From 3e1980283ad555da96bb042a05a81be6627353d9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 30 Sep 2026 12:21:15 -0400 Subject: [PATCH] fix(shorts): hover-chrome, swipe mobile garanti, avance auto fiabilisee 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. --- docs/plan-phases-catalogue-classification.md | 2 + index.css | 29 +++- .../shorts/watch-short.component.html | 38 ++++- .../shorts/watch-short.component.ts | 154 ++++++++++++++++-- 4 files changed, 198 insertions(+), 25 deletions(-) diff --git a/docs/plan-phases-catalogue-classification.md b/docs/plan-phases-catalogue-classification.md index 836ff4e..674a5bd 100644 --- a/docs/plan-phases-catalogue-classification.md +++ b/docs/plan-phases-catalogue-classification.md @@ -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. --- diff --git a/index.css b/index.css index bbbea5c..1845a0c 100644 --- a/index.css +++ b/index.css @@ -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 while a full-screen drawer or modal owns the viewport. Note: deliberately NOT `touch-action: none` — touch-action is intersected up diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index ab4746e..92c9843 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -1,4 +1,6 @@ -
+
+ + + + + -
+
@@ -55,8 +64,23 @@ } @if (current(); as v) { + + + + @if (passthroughHint()) { +
Commandes du lecteur accessibles
+ } + -
+
-
+
diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 50fd350..cc1d407 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -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; } }