feat(shorts): pastilles a logos + filtre de langues preferees
CI / build-and-test (push) Successful in 14m49s

Boutons fournisseurs avec disque aux couleurs de la marque + glyphe SVG (play YT, bulle Twitch, initiale sinon) ; icone etincelles pour Pour toi. Selecteur de langues Profil/FR/EN/FR+EN/Toutes (persiste newtube.shorts.langFilter, invite compris) : chaque flux passe par effectiveShortsLangs (resolveShortsLangs pur + spec). Docs 9.9.
This commit is contained in:
2026-09-30 12:57:31 -04:00
parent 3e1980283a
commit 49859f4e81
5 changed files with 173 additions and 11 deletions
@@ -488,6 +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).
---
+13
View File
@@ -3,6 +3,8 @@ import {
formatShortMeta,
formatCapturedAgo,
formatCompactFr,
resolveShortsLangs,
normalizeShortsLangMode,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
} from './shorts-catalog';
@@ -104,6 +106,17 @@ console.log('--- allowEmbed (Dailymotion Forbidden) ---');
assertEqual(v?.raw, undefined, 'embeddable absent → raw absent (doute profite à la vidéo)');
}
console.log('--- resolveShortsLangs (filtre de préférence) ---');
assertEqual(normalizeShortsLangMode('fr'), 'fr', 'mode fr reconnu');
assertEqual(normalizeShortsLangMode('ES'), 'profile', 'mode inconnu → profile');
assertEqual(normalizeShortsLangMode(null), 'profile', 'mode absent → profile');
assertEqual(resolveShortsLangs('all', ['fr', 'en']), [], 'all → aucune restriction');
assertEqual(resolveShortsLangs('fr', ['fr', 'en']), ['fr'], 'fr → restriction sûre');
assertEqual(resolveShortsLangs('en', ['fr', 'en']), ['en'], 'en → restriction sûre');
assertEqual(resolveShortsLangs('fren', ['fr']), ['fr', 'en'], 'fren → les deux');
assertEqual(resolveShortsLangs('profile', ['en', 'fr', 'fr', 'es-419', 'e']), ['en', 'fr', 'es'], 'profile → normalisé, dédupliqué, base ISO (forme invalide rejetée)');
assertEqual(resolveShortsLangs('profile', null), [], 'profil vide → aucune restriction (pas de vide)');
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');
+43 -2
View File
@@ -138,8 +138,7 @@ export function formatShortMeta(views?: number, publishedAt?: string, now: numbe
* `undefined` → `''` (pas de badge) : un `capturedAt` absent ne doit jamais
* passer pour du frais — doctrine phase 7.3.
*/
export function formatCapturedAgo(capturedAt?: number, now: number = Date.now()): string {
if (!Number.isFinite(Number(capturedAt)) || Number(capturedAt) <= 0) return '';
export function formatCapturedAgo(capturedAt?: number, now: number = Date.now()): string { if (!Number.isFinite(Number(capturedAt)) || Number(capturedAt) <= 0) return '';
const at = Number(capturedAt);
if (at > now) return 'capturé à l’instant';
const min = Math.floor((now - at) / 60000);
@@ -149,3 +148,45 @@ export function formatCapturedAgo(capturedAt?: number, now: number = Date.now())
if (h < 24) return `capturé il y a ${h} h`;
return `capturé il y a ${Math.floor(h / 24)} j`;
}
// ---------------------------------------------------------------------------
// Filtre de langues préférées (boutons + flux Shorts)
// ---------------------------------------------------------------------------
/**
* Mode du filtre de langues de la page Shorts :
* - `profile` : langues du profil utilisateur (UI + préférences + TOUJOURS
* fr/en, voir `getShortsLanguages`) — défaut ;
* - `fr` / `en` / `fren` : restriction explicite (sur-ensemble sûr : le
* serveur a cherché dans les langues du profil, le client ne fait que
* resserrer) ;
* - `all` : aucun filtre (on montre tout ce qui a été collecté).
*/
export type ShortsLangMode = 'profile' | 'fr' | 'en' | 'fren' | 'all';
export const SHORTS_LANG_MODES: readonly ShortsLangMode[] = ['profile', 'fr', 'en', 'fren', 'all'] as const;
/** Valeur stockée (localStorage) → mode valide, `profile` en repli. */
export function normalizeShortsLangMode(raw: unknown): ShortsLangMode {
const s = String(raw || '').trim().toLowerCase();
return (SHORTS_LANG_MODES as readonly string[]).includes(s) ? (s as ShortsLangMode) : 'profile';
}
/**
* Langues effectives d'un mode : `[]` = aucune restriction (tout passe).
* Pur et testé hors-ligne : c'est ce que `shortsLangOk` et tous les flux
* (catalogue, historique, « Pour toi ») consultent — chaque bouton passe
* donc par le même filtre de préférence.
*/
export function resolveShortsLangs(mode: ShortsLangMode, profileLangs: readonly string[] | null | undefined): string[] {
if (mode === 'all') return [];
if (mode === 'fr') return ['fr'];
if (mode === 'en') return ['en'];
if (mode === 'fren') return ['fr', 'en'];
const out: string[] = [];
for (const l of profileLangs || []) {
const base = String(l || '').trim().toLowerCase().split('-')[0];
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
}
return out;
}
@@ -15,22 +15,50 @@
<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 -->
<!-- Sélecteur : Pour toi + fournisseurs (logos) + filtre de langues préférées -->
<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 shrink-0 px-3 py-2 rounded-full text-[13px] border transition-colors min-h-[44px]"
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">
✨ Pour toi
<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>
</span>
Pour toi
</button>
<button *ngFor="let p of providersList()"
<button *ngFor="let p of providerButtons()"
(click)="selectProvider(p.id)"
[attr.aria-pressed]="!forYou() && provider() === p.id"
class="chip shrink-0 px-3 py-2 rounded-full text-[13px] border transition-colors min-h-[44px]"
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() && 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">
@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>
}
@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>
}
@default {
<span class="text-[13px] 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>
</div>
<!-- Scène immersive : flèches (bureau) + cadre 9:16 -->
+83 -4
View File
@@ -1,5 +1,6 @@
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';
@@ -21,6 +22,9 @@ import {
catalogShortToVideo,
formatShortMeta,
formatCapturedAgo,
normalizeShortsLangMode,
resolveShortsLangs,
type ShortsLangMode,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
} from '../../app/search/shorts-catalog';
@@ -30,6 +34,25 @@ const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]),
) as Record<string, ProviderId>;
/** Clé de persistance du filtre de langues (invité compris, sans backend). */
const LS_SHORTS_LANG_KEY = 'newtube.shorts.langFilter';
type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string };
/**
* Pastilles fournisseurs : disque aux couleurs de la marque (mêmes dégradés
* que `ProviderBadgeComponent`) + glyphe blanc — play YouTube, bulle Twitch,
* initiale sinon. Reconnaissable à 24 px sans charger d'assets.
*/
const SHORTS_PROVIDER_STYLE: Record<string, { disc: string; glyph: ShortsGlyph }> = {
youtube: { disc: 'bg-gradient-to-br from-red-600 to-red-500', glyph: { kind: 'play' } },
dailymotion: { disc: 'bg-gradient-to-br from-sky-600 to-sky-500', glyph: { kind: 'letter', letter: 'd' } },
twitch: { disc: 'bg-gradient-to-br from-violet-600 to-purple-500', glyph: { kind: 'chat' } },
peertube: { disc: 'bg-gradient-to-br from-amber-500 to-yellow-400', glyph: { kind: 'letter', letter: 'P' } },
rumble: { disc: 'bg-gradient-to-br from-emerald-600 to-green-500', glyph: { kind: 'letter', letter: 'R' } },
odysee: { disc: 'bg-gradient-to-br from-pink-500 to-fuchsia-500', glyph: { kind: 'letter', letter: 'O' } },
};
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
const TOPIC_STOPWORDS = new Set([
'les', 'des', 'une', 'dans', 'avec', 'pour', 'sur', 'par', 'pas', 'plus', 'tout', 'tous', 'toute', 'toutes',
@@ -63,7 +86,7 @@ const SHORTS_QUERIES: Record<string, string[]> = {
selector: 'app-watch-short',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink],
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './watch-short.component.html'
})
export class WatchShortComponent implements OnDestroy {
@@ -185,6 +208,60 @@ export class WatchShortComponent implements OnDestroy {
readonly provider = computed(() => this.instances.selectedProvider());
readonly providersList = computed(() => this.instances.providers());
/** Pastilles fournisseurs enrichies du style de disque + glyphe (logos). */
readonly providerButtons = computed(() => this.providersList().map((p) => {
const style = SHORTS_PROVIDER_STYLE[String(p?.id || '')] || {
disc: 'bg-slate-700',
glyph: { kind: 'letter', letter: String(p?.label || '?').trim().charAt(0).toUpperCase() } as ShortsGlyph,
};
return { ...p, disc: style.disc, glyph: style.glyph };
}));
/** Lettre du glyphe (`@switch` ne rétrécit pas toujours l'union en template). */
glyphLetter(p: { glyph?: ShortsGlyph } | null | undefined): string {
const g = p?.glyph;
return g && g.kind === 'letter' ? g.letter : '';
}
/**
* Filtre de langues préférées de la page (persisté, invité compris) :
* `profile` = langues du profil utilisateur (défaut), sinon restriction
* explicite. Chaque bouton (chaque provider + « Pour toi ») passe par
* `effectiveShortsLangs()` — un seul filtre pour tout le flux.
*/
readonly langFilter = signal<ShortsLangMode>(((): ShortsLangMode => {
try { return normalizeShortsLangMode(localStorage.getItem(LS_SHORTS_LANG_KEY)); } catch { return 'profile'; }
})());
setLangFilter(mode: ShortsLangMode): void {
const next = normalizeShortsLangMode(mode);
if (next === this.langFilter()) return;
this.langFilter.set(next);
try { localStorage.setItem(LS_SHORTS_LANG_KEY, next); } catch {}
// Le flux courant est filtré avec l'ancien jeu : on recharge pour que le
// changement soit immédiat (même requête, nouveau filtre).
try { this.loadFeed(); } catch {}
}
/** Langues effectives : `[]` = aucune restriction (tout passe). */
private effectiveShortsLangs(): string[] {
try {
const profile = this.api.getShortsLanguages();
return resolveShortsLangs(this.langFilter(), profile);
} catch {
return resolveShortsLangs(this.langFilter(), ['en', 'fr']);
}
}
/** Libellé du filtre actif pour le `title` du sélecteur. */
readonly langFilterHint = computed(() => {
try {
const eff = this.effectiveShortsLangs();
const base = eff.length ? eff.join(', ') : 'toutes langues';
return this.langFilter() === 'profile' ? `Filtre actif : ${base} (profil)` : `Filtre actif : ${base}`;
} catch { return 'Filtre de langues'; }
});
readonly current = computed<Video | null>(() => {
const list = this.items();
const i = this.index();
@@ -1578,7 +1655,8 @@ export class WatchShortComponent implements OnDestroy {
}
/**
* La vidéo respecte-t-elle les langues acceptées (FR/EN + préférences) ?
* La vidéo respecte-t-elle les langues préférées (filtre de la page :
* profil utilisateur par défaut, restriction explicite sinon) ?
* On lit `language` (mappé pour DM/PeerTube/Odysee) ou `raw.language`
* (Twitch). Sans métadonnée, le doute profite à la vidéo : YouTube et
* PeerTube sont de toute façon filtrés côté serveur (relevanceLanguage,
@@ -1588,8 +1666,9 @@ export class WatchShortComponent implements OnDestroy {
const lang = String((v as any)?.language || (v as any)?.raw?.language || '')
.toLowerCase().split('-')[0];
if (!lang || lang === 'other') return true;
let accepted: string[] = ['en', 'fr'];
try { accepted = this.api.getShortsLanguages(); } catch {}
const accepted = this.effectiveShortsLangs();
// `[]` = mode « toutes langues » : tout passe.
if (!accepted.length) return true;
return accepted.includes(lang);
}