feat(shorts): pastilles a logos + filtre de langues preferees
CI / build-and-test (push) Successful in 14m49s
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user