fix(shorts): anti-Forbidden Dailymotion + selecteur icon-only
CI / build-and-test (push) Successful in 14m55s

- DM: embed mute par defaut (comme /watch) + son sur action explicite
- helmet: Referrer-Policy strict-origin-when-cross-origin (Dailymotion exige un Referer)
- searchVideosPage DM: champs allow_embed/width/height/language/owner.id
- iframes shorts/watch: allow fullscreen (conforme embed officiel DM)
- selecteur shorts icon-only (pastilles + globe cyclique)
This commit is contained in:
2026-09-30 19:55:23 -04:00
parent 49859f4e81
commit 6b20b1ce79
6 changed files with 61 additions and 32 deletions
+1 -1
View File
@@ -488,7 +488,7 @@ La table est **additive**. Le Phase 5.3 (LEFT JOIN) est le seul changement de le
- [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).
- [x] Chrome auto-hide au hover (desktop, 2,5 s d'immobilité) ; capteur tactile mobile (l'iframe mange les touchers) : swipe = navigation garantie, tap = play/pause YouTube (API iframe) ou 6 s d'accès direct au lecteur sinon (les boutons du fournisseur restent atteignables) ; flèche retour accueil en haut à gauche sur mobile ; cadre quasi plein écran (`--shorts-chrome: 5,5rem`).
- [x] Avance auto fiabilisée : handshake `listening` YouTube SANS LEQUEL aucun événement player n'arrive (progression + fin mortes), `onStateChange` canonique, réessais bornés quand `next()` est absorbé par un pré-chargement en cours.
- [x] Pastilles à logos (disque aux couleurs de la marque + glyphe : play YouTube, bulle Twitch, initiale sinon ; icône étincelles pour « Pour toi ») et filtre de langues préférées visible (`Profil / Français / Anglais / FR+EN / Toutes`, persisté `newtube.shorts.langFilter`, invité compris) : chaque bouton — chaque provider + « Pour toi », catalogue comme historique — passe par `effectiveShortsLangs()` (`resolveShortsLangs` pur, spec).
- [x] Pastilles icon-only (aucun texte : disque aux couleurs de la marque + glyphe — play YouTube, bulle Twitch, initiale sinon ; étincelles pour « Pour toi » ; globe cyclique Profil → FR → EN → FR+EN → Toutes pour les langues, anneau ambré quand un filtre actif, état dans le tooltip/`aria-label`) et filtre de langues préférées : chaque bouton — chaque provider + « Pour toi », catalogue comme historique — passe par `effectiveShortsLangs()` (`resolveShortsLangs` pur, spec).
---
+6
View File
@@ -957,6 +957,12 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
// Allow loading cross‑origin images
crossOriginResourcePolicy: { policy: 'cross-origin' },
// Dailymotion exige un Referer sur la page d'embed (sans lui, le player
// bloque la lecture). Le défaut helmet (`no-referrer`) coupait le Referer
// de toute la page : on envoie l'origine (recommandation Dailymotion
// "Referrer policy and Player embedding", valeur par défaut des navigateurs
// depuis 2021). Les iframes précisent déjà leur propre referrerpolicy.
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -15,50 +15,51 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 12H5m7-7l-7 7 7 7"/></svg>
</a>
<!-- Sélecteur : Pour toi + fournisseurs (logos) + filtre de langues préférées -->
<!-- Sélecteur icon-only : Pour toi + fournisseurs (logos) + filtre de langues.
Aucun texte visible (logos + title/aria-label) ; le filtre actif se lit
dans le tooltip et l'anneau ambré du globe. -->
<div class="scroll-rail no-scrollbar shorts-chrome relative z-10 flex w-full max-w-full items-center justify-start gap-2 overflow-x-auto px-3 pt-3 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
<button (click)="toggleForYou()"
[attr.aria-pressed]="forYou()"
class="chip flex shrink-0 items-center gap-1.5 px-3 py-2 rounded-full text-[13px] border transition-colors min-h-[44px]"
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-black/60 border-white/15 text-slate-200 hover:bg-white/10'"
title="Shorts personnalisés multi-fournisseurs à partir de vos abonnements, groupes, recherches, likes et téléchargements">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white shadow" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c.7 4.8 3.2 7.3 8 8-4.8.7-7.3 3.2-8 8-.7-4.8-3.2-7.3-8-8 4.8-.7 7.3-3.2 8-8z"/><path d="M19 3c.3 2 1.3 3 3.3 3.3-2 .3-3 1.3-3.3 3.2-.3-1.9-1.3-2.9-3.2-3.2 1.9-.3 2.9-1.3 3.2-3.3z"/></svg>
aria-label="Pour toi"
title="Pour toi — Shorts personnalisés multi-fournisseurs à partir de vos abonnements, groupes, recherches, likes et téléchargements"
class="chip flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-colors"
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-black/60 border-white/15 text-slate-200 hover:bg-white/10'">
<span class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white shadow" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c.7 4.8 3.2 7.3 8 8-4.8.7-7.3 3.2-8 8-.7-4.8-3.2-7.3-8-8 4.8-.7 7.3-3.2 8-8z"/><path d="M19 3c.3 2 1.3 3 3.3 3.3-2 .3-3 1.3-3.3 3.2-.3-1.9-1.3-2.9-3.2-3.2 1.9-.3 2.9-1.3 3.2-3.3z"/></svg>
</span>
Pour toi
</button>
<button *ngFor="let p of providerButtons()"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="!forYou() && provider() === p.id"
class="chip flex shrink-0 items-center gap-1.5 px-3 py-2 rounded-full text-[13px] border transition-colors min-h-[44px]"
[attr.aria-label]="p.label"
[title]="p.label"
class="chip flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-colors"
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-black/60 border-white/15 text-slate-200 hover:bg-white/10'">
<span class="flex h-6 w-6 items-center justify-center rounded-full text-white shadow" [class]="p.disc" aria-hidden="true">
<span class="flex h-7 w-7 items-center justify-center rounded-full text-white shadow" [class]="p.disc" aria-hidden="true">
@switch (p.glyph.kind) {
@case ('play') {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
}
@case ('chat') {
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="13" rx="2"/><path d="M9 16v4l4-4"/><line x1="10" y1="7" x2="10" y2="11"/><line x1="14" y1="7" x2="14" y2="11"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="13" rx="2"/><path d="M9 16v4l4-4"/><line x1="10" y1="7" x2="10" y2="11"/><line x1="14" y1="7" x2="14" y2="11"/></svg>
}
@default {
<span class="text-[13px] font-extrabold leading-none">{{ glyphLetter(p) }}</span>
<span class="text-[15px] font-extrabold leading-none">{{ glyphLetter(p) }}</span>
}
}
</span>
{{ p.label }}
</button>
<!-- Filtre de langues préférées : traverse TOUS les boutons (chaque flux
passe par `effectiveShortsLangs`). Persisté, invité compris. -->
<span class="flex shrink-0 items-center gap-1 rounded-full border border-white/15 bg-black/60 px-2.5 text-[13px] text-slate-200 min-h-[44px]" [title]="langFilterHint()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 opacity-80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<select [ngModel]="langFilter()" (ngModelChange)="setLangFilter($event)" class="bg-transparent py-2 pr-0.5 outline-none cursor-pointer" aria-label="Langues préférées des Shorts">
<option value="profile">Profil</option>
<option value="fr">Français</option>
<option value="en">Anglais</option>
<option value="fren">FR + EN</option>
<option value="all">Toutes</option>
</select>
</span>
<!-- Filtre de langues préférées (globe cyclique, sans texte) : chaque cran
change le filtre de TOUS les flux ; l'anneau ambré signale un filtre
actif (non-profil), le détail est dans le tooltip. -->
<button (click)="cycleLangFilter()"
[title]="langFilterHint()"
[attr.aria-label]="langFilterHint()"
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border transition-colors"
[class]="langFilter() !== 'profile' ? 'bg-black/60 border-amber-400/70 text-amber-200 hover:bg-white/10 ring-2 ring-amber-400/40' : 'bg-black/60 border-white/15 text-slate-200 hover:bg-white/10'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button>
</div>
<!-- Scène immersive : flèches (bureau) + cadre 9:16 -->
@@ -77,7 +78,7 @@
<div #shortsFrame class="shorts-frame relative shrink-0 overflow-hidden bg-black rounded-none ring-0 shadow-none sm:rounded-2xl sm:ring-1 sm:ring-white/10 sm:shadow-2xl">
@if (embedUrl(); as url) {
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" frameborder="0" playsinline
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>
} @else if (loading()) {
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-black text-slate-400 text-sm">
+22 -3
View File
@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, viewChild, HostListener, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@@ -24,6 +23,7 @@ import {
formatCapturedAgo,
normalizeShortsLangMode,
resolveShortsLangs,
SHORTS_LANG_MODES,
type ShortsLangMode,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
@@ -86,7 +86,7 @@ const SHORTS_QUERIES: Record<string, string[]> = {
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink],
imports: [CommonModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent implements OnDestroy {
@@ -244,6 +244,17 @@ export class WatchShortComponent implements OnDestroy {
try { this.loadFeed(); } catch {}
}
/**
* Globe cyclique (bouton icon-only) : Profil → Français → Anglais →
* FR+EN → Toutes → Profil… Le filtre actif se lit dans le tooltip et
* l'anneau ambré ; aucun texte affiché.
*/
cycleLangFilter(): void {
const order = [...SHORTS_LANG_MODES];
const at = order.indexOf(this.langFilter());
this.setLangFilter(order[(at + 1) % order.length]);
}
/** Langues effectives : `[]` = aucune restriction (tout passe). */
private effectiveShortsLangs(): string[] {
try {
@@ -367,7 +378,15 @@ export class WatchShortComponent implements OnDestroy {
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
// en query sans Player ID ; autoplay est tenté au mieux.
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=${this.muted() ? 1 : 0}&autoplay=1`;
// Correctif bug "Forbidden" systématique en Shorts alors que /watch
// (toujours muet) joue la même vidéo : on démarre MUET comme /watch
// (même URL, mute=1) et on ne passe au son QUE sur action explicite
// du bouton Son — le clic EST le geste qui autorise le son (même
// motif que le player Odysee, cf. soundTouched). L'autoplay sonore
// sans geste préalable est bloqué et le player par défaut l'affiche
// comme "Forbidden".
const withSound = !this.muted() && this.soundTouched();
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=${withSound ? 0 : 1}&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch' && v.videoId) {
+1 -1
View File
@@ -26,7 +26,7 @@
@if (embedUrl(); as src) {
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0" playsinline (load)="onEmbedFrameLoad()"
referrerpolicy="origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen title="Lecteur vidéo"></iframe>
} @else if (videoSource()) {
<app-video-player
+4 -1
View File
@@ -764,7 +764,10 @@ export class YoutubeApiService {
limit: String(limit),
page: String(page),
country: String(region).toLowerCase(),
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time',
// `allow_embed` indispensable : sans lui, les vidéos non-embeddables
// passent le filtre et le player répond "Forbidden" (même garde que
// searchShortsPage ; `width,height` = validation verticale Shorts).
fields: 'id,title,thumbnail_url,thumbnail_480_url,thumbnail_720_url,duration,width,height,views_total,language,allow_embed,owner.id,owner.screenname,owner.avatar_80_url,created_time',
search: String(query),
});
return this.fetchDailymotion('/videos', params).pipe(