diff --git a/server/providers/dailymotion.mjs b/server/providers/dailymotion.mjs index a8e78b7..d6226d9 100644 --- a/server/providers/dailymotion.mjs +++ b/server/providers/dailymotion.mjs @@ -20,7 +20,11 @@ const handler = { page: Math.max(1, Number(page || 1)).toString(), // Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date) 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), uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '', 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(h) && h > 0 ? { height: h } : {}), }; diff --git a/src/app/search/adapters/dm.ts b/src/app/search/adapters/dm.ts index 25d34bb..217dc0f 100644 --- a/src/app/search/adapters/dm.ts +++ b/src/app/search/adapters/dm.ts @@ -35,6 +35,11 @@ export class DmAdapter implements ProviderAdapter { publishedAt: undefined, // Dailymotion API does not provide published date in search results channelExternalId: serverRefValue(it.channelRef, 'dm') || it.channelId || 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. type: typeof it.type === 'string' ? it.type : undefined, isShort: it.isShort === true ? true : undefined, diff --git a/src/app/search/shorts-catalog.spec.ts b/src/app/search/shorts-catalog.spec.ts index efc2771..3a0cbf4 100644 --- a/src/app/search/shorts-catalog.spec.ts +++ b/src/app/search/shorts-catalog.spec.ts @@ -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 + 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 ---'); assertEqual(SHORTS_CATALOG_PAGE_SIZE, 25, 'page catalogue 25 (serveur plafonne à 50)'); assertEqual(SHORTS_CATALOG_MIN_ITEMS, 3, 'seuil de repli legacy'); diff --git a/src/app/search/shorts-catalog.ts b/src/app/search/shorts-catalog.ts index bbf6ff3..b9a6efb 100644 --- a/src/app/search/shorts-catalog.ts +++ b/src/app/search/shorts-catalog.ts @@ -49,6 +49,12 @@ export function catalogShortToVideo(item: VideoItem | null | undefined): Video | const kind = kindRaw === 'clip' || kindRaw === 'vod' || kindRaw === 'short' || kindRaw === 'channel' ? kindRaw : 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 { url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '', 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, // 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. - raw: (slug || capturedAt !== undefined || source) - ? { ...(slug ? { slug } : {}), ...(capturedAt !== undefined ? { capturedAt } : {}), ...(source ? { source } : {}) } + // (`allowEmbed` y voyage aussi : c'est la clé lue par `isPlayable`.) + raw: (slug || capturedAt !== undefined || source || allowEmbed !== undefined) + ? { + ...(slug ? { slug } : {}), + ...(capturedAt !== undefined ? { capturedAt } : {}), + ...(source ? { source } : {}), + ...(allowEmbed !== undefined ? { allowEmbed } : {}), + } : undefined, }; } diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index f33edb8..1f8d64a 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -100,15 +100,19 @@ export class WatchShortComponent implements OnDestroy { /** Cadre 9:16 (plein écran + focus). */ frameRef = viewChild>('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/ (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 {}