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:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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