From 8f100d2b78fd1deebf3a03a6551cfbb2bc7acb47 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 30 Sep 2026 11:36:35 -0400 Subject: [PATCH] fix(shorts): Odysee sans tap-to-unmute + avance auto tous providers Odysee demarre 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). Avance auto : playerState YT (fin -> suivant, pause -> minuteur desarme, reprise -> rearme) + minuteur generique sur duree connue ou estimee (clip Twitch 60 s, sinon plafond Short 90 s). Docs 9.9. --- docs/plan-phases-catalogue-classification.md | 3 + .../shorts/watch-short.component.ts | 79 +++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/plan-phases-catalogue-classification.md b/docs/plan-phases-catalogue-classification.md index f870167..836ff4e 100644 --- a/docs/plan-phases-catalogue-classification.md +++ b/docs/plan-phases-catalogue-classification.md @@ -483,6 +483,9 @@ La table est **additive**. Le Phase 5.3 (LEFT JOIN) est le seul changement de le - [x] Pont pur `src/app/search/shorts-catalog.ts` (`catalogShortToVideo`, `formatShortMeta`, `formatCapturedAgo`) + spec hors-ligne `npm run test:shorts-catalog` (27 assertions : pas d'id fantôme, pas de fraîcheur inventée, pas de « 0 vue »). - [x] Avatars paresseux via `channelMeta` (`GET /channels/:provider/:externalId`, une requête par vidéo, silence en échec) + initiale de repli — plus d'image cassée quand la recherche ne fournit pas d'avatar. - [x] Refonte pro façon YouTube + mobile : scène plein écran (`100dvh`, fond d'ambiance flouté), rail d'actions **overlay dans le cadre** (J'aime / Commentaires / Partager / Son, cibles 48 px), méta sous le titre (« 12 k vues • il y a 2 j » + « capturé il y a 4 min • api »), pastille provider, safe-area, `touch-action: pan-y`, cadre sans radius sur mobile. +- [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). --- diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 1f8d64a..50fd350 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -327,7 +327,13 @@ export class WatchShortComponent implements OnDestroy { if (p === 'odysee') { const slug = this.odyseeSlugOf(v); if (slug) { - const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=${this.muted() ? 1 : 0}`; + // Le player Odysee bloque l'autoplay sonore sans geste préalable et + // affiche un rectangle « tap to unmute » à la place de la vidéo : + // on démarre donc MUET (lecture immédiate, pas d'overlay) et on ne + // passe au son QUE sur action explicite du bouton Son — le clic EST + // le geste qui autorise l'autoplay sonore, donc pas de rectangle. + const withSound = !this.muted() && this.soundTouched(); + const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=${withSound ? 0 : 1}`; return this.sanitizer.bypassSecurityTrustResourceUrl(u); } } @@ -595,13 +601,34 @@ export class WatchShortComponent implements OnDestroy { private autoAdvanceTimer: any = null; private autoAdvanceKey = ''; + /** + * Durée effective pour l'avance auto quand la durée réelle est inconnue. + * - clips Twitch : 60 s (plafond plateforme, par construction) ; + * - autres : 90 s (`VERTICAL_SHORT_MAX_SECONDS`, plafond d'un Short). + * + * Compromis assumé : sans signal de fin du player, on ne peut pas savoir + * quand une vidéo sans durée se termine. Le plafond 90 s borne le pire cas + * (écran de fin), et une vidéo plus longue égarée dans le flux est coupée + * à 90 s — acceptable pour un flux Shorts, la navigation manuelle restant + * toujours disponible. Dès que la durée est connue, elle prime. + */ + private estimatedDuration(v: Video): number { + const kind = String((v as unknown as { kind?: unknown })?.kind || '').toLowerCase(); + const p = String((v as unknown as { provider?: unknown })?.provider || '').toLowerCase(); + if (kind === 'clip' || p === 'twitch') return 60; + return 90; + } + /** * Avance auto en fin de lecture : - * - YouTube : événement `infoDelivery` temps réel (voir handleShortMessage) ; - * - tous : minuteur basé sur la durée connue (+ 2,5 s de grâce pour le - * buffering). Gardé par (index, videoId) : si l'utilisateur a déjà - * navigué, le timer ne fait rien. Ignoré si l'onglet est caché (on ne - * fait pas défiler un flux invisible). + * - YouTube : `playerState` + `infoDelivery` temps réel (voir + * handleShortMessage : 0 = fin → suivant, 2 = pause → minuteur désarmé, + * 1 = reprise → minuteur réarmé) ; + * - tous : minuteur basé sur la durée connue (ou estimée, voir + * `estimatedDuration`) + 2,5 s de grâce pour le buffering. Gardé par + * (index, videoId) : si l'utilisateur a déjà navigué, le timer ne fait + * rien. Ignoré si l'onglet est caché (on ne fait pas défiler un flux + * invisible). */ private scheduleAutoAdvance(v: Video | null): void { try { @@ -609,7 +636,8 @@ export class WatchShortComponent implements OnDestroy { } catch {} this.autoAdvanceKey = ''; if (!v?.videoId) return; - const secs = 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); if (!Number.isFinite(secs) || secs <= 0) return; const atIndex = this.index(); const id = String(v.videoId); @@ -702,7 +730,16 @@ export class WatchShortComponent implements OnDestroy { } // ---- Actions façon YouTube Shorts ---- + /** + * Geste sonore EXPLICITE (clic sur le bouton Son). Certains players + * (Odysee) refusent le son sans geste : ce drapeau distingue « son par + * défaut » (tentative, peut être bloquée) de « son demandé » (le clic EST + * l'activation qui autorise l'autoplay sonore). + */ + private soundTouched = signal(false); + toggleMute(): void { + this.soundTouched.set(true); this.muted.update(m => !m); } @@ -824,6 +861,34 @@ export class WatchShortComponent implements OnDestroy { const ct = Number(data.info.currentTime); let dur = Number(data.info.duration); if (!Number.isFinite(dur) || dur <= 0) dur = Number((v as any)?.duration || 0); + // `playerState` YouTube : 0 = fin, 1 = lecture, 2 = pause. + const state = Number(data.info.playerState); + const cur = this.current(); + const same = !!cur && String(cur.videoId) === String(v.videoId); + if (Number.isFinite(state) && same) { + 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(); + } + 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.autoAdvanceKey = `paused:${id}`; + return; + } + if (state === 1 && this.autoAdvanceKey.startsWith('paused:')) { + // Reprise après pause : on réarme le minuteur (repli, la fin + // temps réel prime de toute façon). + this.scheduleAutoAdvance(v); + return; + } + } if (Number.isFinite(ct) && ct >= 0 && dur > 0) { this.ytProgress.set(Math.max(0, Math.min(1, ct / dur))); // Fin de lecture (marge 0,75 s : l'événement arrive par salves) →