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 = Object.fromEntries( Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]), ) as Record; +/** 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 = { + 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 = { 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 => { + 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