fix(shorts): son par defaut, avance auto, anti-Forbidden DM, autoplay Twitch/Rumble
CI / build-and-test (push) Successful in 14m56s
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:
@@ -20,7 +20,11 @@ const handler = {
|
|||||||
page: Math.max(1, Number(page || 1)).toString(),
|
page: Math.max(1, Number(page || 1)).toString(),
|
||||||
// Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date)
|
// Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date)
|
||||||
sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')),
|
sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')),
|
||||||
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,width,height,owner.id,owner.screenname,owner.avatar_80_url,created_time'
|
// `allow_embed` est indispensable : une vidéo dont le propriétaire a
|
||||||
|
// désactivé l'embed répond "Forbidden" dans le player. Propagé tel
|
||||||
|
// quel (`embeddable`) pour que le front l'exclue AVANT l'affichage
|
||||||
|
// (même garde que le chemin historique `raw.allowEmbed`).
|
||||||
|
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,width,height,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,6 +51,9 @@ const handler = {
|
|||||||
views: Number(item.views_total || 0),
|
views: Number(item.views_total || 0),
|
||||||
uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '',
|
uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '',
|
||||||
type: 'video',
|
type: 'video',
|
||||||
|
// `false` explicite uniquement : champ absent = on ne sait pas (le
|
||||||
|
// doute profite à la vidéo, doctrine « absent = absent »).
|
||||||
|
...(typeof item.allow_embed === 'boolean' ? { embeddable: item.allow_embed } : {}),
|
||||||
...(Number.isFinite(w) && w > 0 ? { width: w } : {}),
|
...(Number.isFinite(w) && w > 0 ? { width: w } : {}),
|
||||||
...(Number.isFinite(h) && h > 0 ? { height: h } : {}),
|
...(Number.isFinite(h) && h > 0 ? { height: h } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||||
channelExternalId: serverRefValue(it.channelRef, 'dm') || it.channelId || undefined,
|
channelExternalId: serverRefValue(it.channelRef, 'dm') || it.channelId || undefined,
|
||||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||||
|
// Jouabilité : `allow_embed=false` (propriétaire) => le player répond
|
||||||
|
// "Forbidden". Propagé tel quel : `accepts()` l'exclut via le garde
|
||||||
|
// `raw.allowEmbed` (même garde que le chemin historique direct).
|
||||||
|
// Champ absent = on ne sait pas (jamais de `false` inventé).
|
||||||
|
embeddable: typeof it.embeddable === 'boolean' ? it.embeddable : undefined,
|
||||||
// Signaux de type pour le raffinement client des filtres.
|
// Signaux de type pour le raffinement client des filtres.
|
||||||
type: typeof it.type === 'string' ? it.type : undefined,
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
isShort: it.isShort === true ? true : undefined,
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
|||||||
@@ -84,6 +84,26 @@ assertEqual(formatCapturedAgo(NOW - 30000, NOW), 'capturé à l’instant', 'fra
|
|||||||
assertEqual(formatCapturedAgo(NOW - 4 * 60000, NOW), 'capturé il y a 4 min', 'minutes');
|
assertEqual(formatCapturedAgo(NOW - 4 * 60000, NOW), 'capturé il y a 4 min', 'minutes');
|
||||||
assertEqual(formatCapturedAgo(NOW + 60000, NOW), 'capturé à l’instant', 'futur → à l’instant (jamais « dans 1 min »)');
|
assertEqual(formatCapturedAgo(NOW + 60000, NOW), 'capturé à l’instant', 'futur → à l’instant (jamais « dans 1 min »)');
|
||||||
|
|
||||||
|
console.log('--- allowEmbed (Dailymotion Forbidden) ---');
|
||||||
|
{
|
||||||
|
const v: any = catalogShortToVideo({
|
||||||
|
id: 'd1', provider: 'dailymotion', title: 'D', thumbnailUrl: '', channelName: 'C', embeddable: false,
|
||||||
|
} as any);
|
||||||
|
assertEqual(v?.raw?.allowEmbed, false, 'embeddable=false → raw.allowEmbed=false (garde isPlayable)');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const v: any = catalogShortToVideo({
|
||||||
|
id: 'd2', provider: 'dailymotion', title: 'D', thumbnailUrl: '', channelName: 'C', embeddable: true,
|
||||||
|
} as any);
|
||||||
|
assertEqual(v?.raw?.allowEmbed, true, 'embeddable=true propagé');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const v: any = catalogShortToVideo({
|
||||||
|
id: 'd3', provider: 'dailymotion', title: 'D', thumbnailUrl: '', channelName: 'C',
|
||||||
|
} as any);
|
||||||
|
assertEqual(v?.raw, undefined, 'embeddable absent → raw absent (doute profite à la vidéo)');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('--- constantes ---');
|
console.log('--- constantes ---');
|
||||||
assertEqual(SHORTS_CATALOG_PAGE_SIZE, 25, 'page catalogue 25 (serveur plafonne à 50)');
|
assertEqual(SHORTS_CATALOG_PAGE_SIZE, 25, 'page catalogue 25 (serveur plafonne à 50)');
|
||||||
assertEqual(SHORTS_CATALOG_MIN_ITEMS, 3, 'seuil de repli legacy');
|
assertEqual(SHORTS_CATALOG_MIN_ITEMS, 3, 'seuil de repli legacy');
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export function catalogShortToVideo(item: VideoItem | null | undefined): Video |
|
|||||||
const kind = kindRaw === 'clip' || kindRaw === 'vod' || kindRaw === 'short' || kindRaw === 'channel'
|
const kind = kindRaw === 'clip' || kindRaw === 'vod' || kindRaw === 'short' || kindRaw === 'channel'
|
||||||
? kindRaw
|
? kindRaw
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Jouabilité Dailymotion : `embeddable === false` (serveur lit `allow_embed`)
|
||||||
|
// => le player répondrait "Forbidden". Transmis dans `raw` pour que le garde
|
||||||
|
// existant `isPlayable` (`v?.raw?.allowEmbed === false`) l'exclue du flux,
|
||||||
|
// exactement comme le chemin historique direct. Absent = on ne sait pas.
|
||||||
|
const embeddableRaw = (item as { embeddable?: unknown }).embeddable;
|
||||||
|
const allowEmbed = typeof embeddableRaw === 'boolean' ? embeddableRaw : undefined;
|
||||||
return {
|
return {
|
||||||
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '',
|
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '',
|
||||||
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
||||||
@@ -71,8 +77,14 @@ export function catalogShortToVideo(item: VideoItem | null | undefined): Video |
|
|||||||
language: typeof item.language === 'string' ? item.language : undefined,
|
language: typeof item.language === 'string' ? item.language : undefined,
|
||||||
// Provenance catalogue : le flux l'affiche (« capturé il y a X »), le
|
// Provenance catalogue : le flux l'affiche (« capturé il y a X »), le
|
||||||
// modèle historique n'a pas ces champs → repli `raw`, jamais de perte.
|
// modèle historique n'a pas ces champs → repli `raw`, jamais de perte.
|
||||||
raw: (slug || capturedAt !== undefined || source)
|
// (`allowEmbed` y voyage aussi : c'est la clé lue par `isPlayable`.)
|
||||||
? { ...(slug ? { slug } : {}), ...(capturedAt !== undefined ? { capturedAt } : {}), ...(source ? { source } : {}) }
|
raw: (slug || capturedAt !== undefined || source || allowEmbed !== undefined)
|
||||||
|
? {
|
||||||
|
...(slug ? { slug } : {}),
|
||||||
|
...(capturedAt !== undefined ? { capturedAt } : {}),
|
||||||
|
...(source ? { source } : {}),
|
||||||
|
...(allowEmbed !== undefined ? { allowEmbed } : {}),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,15 +100,19 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
/** Cadre 9:16 (plein écran + focus). */
|
/** Cadre 9:16 (plein écran + focus). */
|
||||||
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
||||||
/**
|
/**
|
||||||
* Son coupé ou non. Démarrer MUET est la seule façon d'obtenir l'autoplay
|
* Son ACTIVÉ par défaut : les embeds YouTube / Odysee / Dailymotion /
|
||||||
* garanti des iframes (politique navigateur) — c'est aussi ce que fait la
|
* PeerTube démarrent avec le son (`mute=0`). C'est une tentative — le
|
||||||
* page Watch (mute=1). L'utilisateur active le son via le bouton dédié :
|
* navigateur peut bloquer l'autoplay sonore sans interaction préalable,
|
||||||
* un clic sur ce bouton recharge l'embed AVEC son tout en restant dans un
|
* auquel cas l'utilisateur tape une fois sur la vidéo (geste qui débloque
|
||||||
* contexte de geste utilisateur (autoplay autorisé). On ne débloque jamais
|
* les suivantes).
|
||||||
* le son automatiquement : recharger l'embed pendant un scroll relançait un
|
*
|
||||||
* embed non lisible (écran noir).
|
* 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
|
* Embed Rumble autoritaire : id demandé -> vrai id scrapé via
|
||||||
* /api/rumble/video/<id> (comme /watch). Les IDs de liste approximatifs
|
* /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).
|
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
|
||||||
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
|
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
|
||||||
if (clipId) {
|
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);
|
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||||
}
|
}
|
||||||
// Repli : player channel/VOD (ne devrait pas arriver, les lives
|
// 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
|
// Avatar paresseux via le catalogue (`channelMeta`) : le flux n'affiche
|
||||||
// plus une image cassée quand la recherche ne fournit pas d'avatar.
|
// plus une image cassée quand la recherche ne fournit pas d'avatar.
|
||||||
this.ensureAvatar(v);
|
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).
|
// État J'aime du short courant (connecté uniquement).
|
||||||
try {
|
try {
|
||||||
if (this.isLoggedIn() && v.videoId) {
|
if (this.isLoggedIn() && v.videoId) {
|
||||||
@@ -536,6 +551,8 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
try { window.removeEventListener('message', this.handleShortMessage); } catch {}
|
try { window.removeEventListener('message', this.handleShortMessage); } catch {}
|
||||||
try { document.removeEventListener('fullscreenchange', this.handleFullscreenChange); } 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 {} }
|
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +591,63 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
} catch {}
|
} 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é
|
* 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
|
* 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(dur) || dur <= 0) dur = Number((v as any)?.duration || 0);
|
||||||
if (Number.isFinite(ct) && ct >= 0 && dur > 0) {
|
if (Number.isFinite(ct) && ct >= 0 && dur > 0) {
|
||||||
this.ytProgress.set(Math.max(0, Math.min(1, ct / dur)));
|
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 {}
|
} catch {}
|
||||||
|
|||||||
Reference in New Issue
Block a user