fix(shorts): son par defaut, avance auto, anti-Forbidden DM, autoplay Twitch/Rumble
CI / build-and-test (push) Successful in 14m56s

Son active par defaut (YT/OD/DM/PT avec son ; clips Twitch en muet force, sans API de volume). Avance auto en fin de lecture (infoDelivery YT + minuteur duree generique garde par index/videoId, ignore si onglet cache). Dailymotion : allow_embed demande cote serveur, mappe en embeddable, propage en raw.allowEmbed pour le garde isPlayable (fini les Forbidden). Rumble autoplay=1/2 selon muet + nudge playVideo YT.
This commit is contained in:
2026-09-30 10:40:03 -04:00
parent 8e087daf93
commit 8e5f1d6c65
5 changed files with 144 additions and 12 deletions
+97 -9
View File
@@ -100,15 +100,19 @@ export class WatchShortComponent implements OnDestroy {
/** Cadre 9:16 (plein écran + focus). */
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
/**
* Son coupé ou non. Démarrer MUET est la seule façon d'obtenir l'autoplay
* garanti des iframes (politique navigateur) — c'est aussi ce que fait la
* page Watch (mute=1). L'utilisateur active le son via le bouton dédié :
* un clic sur ce bouton recharge l'embed AVEC son tout en restant dans un
* contexte de geste utilisateur (autoplay autorisé). On ne débloque jamais
* le son automatiquement : recharger l'embed pendant un scroll relançait un
* embed non lisible (écran noir).
* Son ACTIVÉ par défaut : les embeds YouTube / Odysee / Dailymotion /
* PeerTube démarrent avec le son (`mute=0`). C'est une tentative — le
* navigateur peut bloquer l'autoplay sonore sans interaction préalable,
* auquel cas l'utilisateur tape une fois sur la vidéo (geste qui débloque
* les suivantes).
*
* Exceptions (muet forcé, autoplay garanti) :
* - Twitch clips : l'embed `clips.twitch.tv` n'expose AUCUNE API de son —
* `muted=true` est la seule combinaison qui démarre sans geste ;
* - Rumble : `autoplay=1` (son) par défaut, `autoplay=2` (muet) si
* l'utilisateur coupe le son.
*/
muted = signal(true);
muted = signal(false);
/**
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
@@ -295,7 +299,11 @@ export class WatchShortComponent implements OnDestroy {
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
if (clipId) {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=${this.muted()}`;
// `muted=true` FORCÉ (indépendant du bouton Son) : l'embed clips
// n'expose aucune API de volume — muet est la seule combinaison
// qui démarre sans geste. Le son reste disponible via « Ouvrir
// chez le fournisseur » / Watch.
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=true`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
// Repli : player channel/VOD (ne devrait pas arriver, les lives
@@ -511,6 +519,13 @@ export class WatchShortComponent implements OnDestroy {
// Avatar paresseux via le catalogue (`channelMeta`) : le flux n'affiche
// plus une image cassée quand la recherche ne fournit pas d'avatar.
this.ensureAvatar(v);
// Avance auto en fin de vidéo (minuteur générique ; YouTube a en plus
// son événement temps réel, voir handleShortMessage).
this.scheduleAutoAdvance(v);
// Nudge YouTube : si l'autoplay a calé (politique navigateur), une
// commande `playVideo` explicite relance la lecture dès qu'une
// activation utilisateur existe. Sans effet sinon (jamais d'erreur).
this.nudgeYouTubePlay(v);
// État J'aime du short courant (connecté uniquement).
try {
if (this.isLoggedIn() && v.videoId) {
@@ -536,6 +551,8 @@ export class WatchShortComponent implements OnDestroy {
ngOnDestroy(): void {
try { window.removeEventListener('message', this.handleShortMessage); } catch {}
try { document.removeEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
try { if (this.autoAdvanceTimer) clearTimeout(this.autoAdvanceTimer); } catch {}
this.autoAdvanceTimer = null;
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
}
@@ -574,6 +591,63 @@ export class WatchShortComponent implements OnDestroy {
} catch {}
}
/** Timer d'avance auto en fin de vidéo (repli générique). */
private autoAdvanceTimer: any = null;
private autoAdvanceKey = '';
/**
* 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).
*/
private scheduleAutoAdvance(v: Video | null): void {
try {
if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; }
} catch {}
this.autoAdvanceKey = '';
if (!v?.videoId) return;
const secs = Number((v as Video).duration || 0);
if (!Number.isFinite(secs) || secs <= 0) return;
const atIndex = this.index();
const id = String(v.videoId);
const key = `${atIndex}:${id}`;
this.autoAdvanceKey = key;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
try {
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();
} catch {}
}, Math.min(600000, (secs + 2.5) * 1000));
}
/**
* 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).
* N'agit que sur le short courant (gardé par videoId au moment du tir).
*/
private nudgeYouTubePlay(v: Video | null): void {
try {
if (!v?.videoId || this.providerOf(v) !== 'youtube') return;
const id = String(v.videoId);
const atIndex = this.index();
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: '' }), '*');
} catch {}
}, 1500);
} 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
@@ -752,6 +826,20 @@ export class WatchShortComponent implements OnDestroy {
if (!Number.isFinite(dur) || dur <= 0) dur = Number((v as any)?.duration || 0);
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) →
// short suivant, façon YouTube. Gardé par videoId : l'événement ne
// doit jamais faire avancer une vidéo déjà quittée. La clé écrase
// aussi le minuteur générique (même garde).
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();
}
}
}
}
}
} catch {}