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
+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);
}