diff --git a/docs/plan-phases-catalogue-classification.md b/docs/plan-phases-catalogue-classification.md index 674a5bd..9effca7 100644 --- a/docs/plan-phases-catalogue-classification.md +++ b/docs/plan-phases-catalogue-classification.md @@ -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). --- diff --git a/src/app/search/shorts-catalog.spec.ts b/src/app/search/shorts-catalog.spec.ts index 3a0cbf4..bbf99b9 100644 --- a/src/app/search/shorts-catalog.spec.ts +++ b/src/app/search/shorts-catalog.spec.ts @@ -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'); diff --git a/src/app/search/shorts-catalog.ts b/src/app/search/shorts-catalog.ts index b9a6efb..215dbbb 100644 --- a/src/app/search/shorts-catalog.ts +++ b/src/app/search/shorts-catalog.ts @@ -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; +} diff --git a/src/components/shorts/watch-short.component.html b/src/components/shorts/watch-short.component.html index 92c9843..29f8bc9 100644 --- a/src/components/shorts/watch-short.component.html +++ b/src/components/shorts/watch-short.component.html @@ -15,22 +15,50 @@ - +
diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index cc1d407..c884b33 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -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