fix(shorts): Odysee sans tap-to-unmute + avance auto tous providers
CI / build-and-test (push) Successful in 14m48s
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:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) →
|
||||
|
||||
Reference in New Issue
Block a user