fix(shorts): Odysee sans tap-to-unmute + avance auto tous providers
CI / build-and-test (push) Successful in 14m48s

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.
This commit is contained in:
2026-09-30 11:36:45 -04:00
parent 8e5f1d6c65
commit 8f100d2b78
2 changed files with 75 additions and 7 deletions
@@ -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).
---
+72 -7
View File
@@ -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) →