feat(shorts): page /shorts alimentee par le catalogue + refonte pro/mobile
CI / build-and-test (push) Successful in 14m45s

SearchService.fetchCatalogShorts (type shorts via /api/search : cache, provenance, FF) + pont pur shorts-catalog.ts (mapping, meta, fraicheur, spec 27 assertions). WatchShortComponent : catalogue d'abord (feed, pagination, Pour toi), repli legacy, Twitch clips inchanges, avatars paresseux via channelMeta. UI facon YouTube : plein ecran 100dvh, fond d'ambiance, rail overlay 48px, meta/fraicheur/pastille provider, safe-area.
This commit is contained in:
2026-09-30 09:16:56 -04:00
parent 665a0f0ebd
commit 8e087daf93
9 changed files with 691 additions and 84 deletions
+269 -13
View File
@@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom, forkJoin, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { firstValueFrom, forkJoin, from, map, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
@@ -15,6 +15,15 @@ import { AuthService } from '../../services/auth.service';
import { Video } from '../../models/video.model';
import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
import { SHORT_TO_LONG, type ProviderId } from '../../app/shared/providers/provider-ids';
import { SearchService } from '../../app/search/search.service';
import { ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
import {
catalogShortToVideo,
formatShortMeta,
formatCapturedAgo,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
} from '../../app/search/shorts-catalog';
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
@@ -67,6 +76,14 @@ export class WatchShortComponent implements OnDestroy {
private downloads = inject(DownloadService);
private subs = inject(SubscriptionsService);
private auth = inject(AuthService);
/**
* Phase Shorts-catalogue — le flux est alimenté par le catalogue unifié
* (`/api/search?type=shorts` : cache, provenance, feature-flags, capacités),
* le chemin historique direct restant en repli (Twitch clips, panne serveur,
* provider sans équivalent Shorts catalogue).
*/
private catalog = inject(SearchService);
private channelStrategies = inject(ChannelProviderFactory);
loading = signal(true);
error = signal<string | null>(null);
@@ -144,6 +161,17 @@ export class WatchShortComponent implements OnDestroy {
private queryIndex = 0;
/** Requêtes déjà épuisées (curseur null) pour ce chargement. */
private exhaustedQueries = new Set<string>();
/**
* Alimentation catalogue active : la pagination suit alors les pages
* `/api/search` (`catalogPage`), pas les curseurs du chemin historique.
* Toute bascule vers le repli historique remet ce drapeau à `false`.
*/
private catalogActive = false;
private catalogPage = 1;
/** Erreur non bloquante du catalogue (« source dégradée », esprit 7.4). */
readonly catalogError = signal<string | null>(null);
/** Clés déjà tentées pour la résolution paresseuse d'avatar (anti-boucle). */
private avatarResolved = new Set<string>();
// scroll/swipe helpers
private wheelAccum = 0;
@@ -159,6 +187,47 @@ export class WatchShortComponent implements OnDestroy {
return (i >= 0 && i < list.length) ? list[i] : null;
});
/**
* Méta façon YouTube sous le titre (« 12 k vues • il y a 2 j ») : vient du
* catalogue (vues + date réelles), chaque morceau inconnu est omis.
*/
readonly currentMeta = computed(() => {
const v = this.current();
if (!v) return '';
try { return formatShortMeta((v as Video).views, (v as Video).uploadedDate); } catch { return ''; }
});
/** Fraîcheur de collecte catalogue (« capturé il y a 4 min », '' si inconnue). */
readonly currentCaptured = computed(() => {
const v = this.current();
if (!v) return '';
try {
const raw = (v as unknown as { raw?: { capturedAt?: number } })?.raw;
return formatCapturedAgo(raw?.capturedAt);
} catch { return ''; }
});
/** Voie de collecte catalogue (`api`, `innertube`, `cache`… — '' si inconnue). */
readonly currentSource = computed(() => {
const v = this.current();
if (!v) return '';
try {
const raw = (v as unknown as { raw?: { source?: unknown } })?.raw;
const s = String(raw?.source || '').trim();
return s || '';
} catch { return ''; }
});
/** Libellé du fournisseur courant (pastille façon YouTube). */
readonly providerLabel = computed(() => {
try {
const list = this.providersList() as Array<{ id?: unknown; label?: unknown }>;
const hit = (list || []).find((p) => String(p?.id || '') === String(this.provider()));
const label = String(hit?.label || '').trim();
return label || String(this.provider());
} catch { return String(this.provider()); }
});
readonly canPrev = computed(() => this.index() > 0);
readonly canNext = computed(() =>
this.index() < this.items().length - 1 || (!this.forYou() && (!!this.nextCursor() || this.hasMoreQueries()))
@@ -439,6 +508,9 @@ export class WatchShortComponent implements OnDestroy {
if (!v) return;
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
this.queueRumbleResolves([v, nx]);
// Avatar paresseux via le catalogue (`channelMeta`) : le flux n'affiche
// plus une image cassée quand la recherche ne fournit pas d'avatar.
this.ensureAvatar(v);
// État J'aime du short courant (connecté uniquement).
try {
if (this.isLoggedIn() && v.videoId) {
@@ -467,6 +539,41 @@ export class WatchShortComponent implements OnDestroy {
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
}
/**
* Résout l'avatar de la chaîne du short courant via le catalogue
* (`GET /channels/:provider/:externalId`, une seule requête par vidéo grâce
* au garde `avatarResolved`). Le lot est patché immuablement pour rester
* compatible OnPush. Silence total en échec : pas d'avatar vaut mieux qu'un
* spinner ou une erreur dans un flux infini.
*/
private ensureAvatar(v: Video | null): void {
try {
if (!v?.videoId || v.uploaderAvatar) return;
const extId = String((v as unknown as { channelExternalId?: unknown })?.channelExternalId || '').trim();
if (!extId) return;
const p = this.providerOf(v);
const short = SUB_PROVIDER_OF[p];
if (!short) return;
const key = `${p}:${String(v.videoId)}`;
if (this.avatarResolved.has(key)) return;
this.avatarResolved.add(key);
this.channelStrategies.get(short).getChannelInfo(extId).subscribe({
next: (detail) => {
const avatar = String((detail as unknown as { avatarUrl?: unknown })?.avatarUrl || '').trim();
if (!avatar) return;
const at = this.items().findIndex((x) =>
String(x.videoId) === String(v.videoId) &&
String((x as unknown as { provider?: unknown })?.provider || '') === String((v as unknown as { provider?: unknown })?.provider || ''));
if (at < 0 || this.items()[at]?.uploaderAvatar) return;
const next = this.items().slice();
next[at] = { ...next[at], uploaderAvatar: avatar };
this.items.set(next);
},
error: () => {},
});
} catch {}
}
/**
* File de résolution Rumble (concurrence bornée) : chaque id est vérifié
* via le backend (comme /watch). Succès => l'embed bascule sur le vrai
@@ -781,9 +888,32 @@ export class WatchShortComponent implements OnDestroy {
return out;
}
/**
* Voie catalogue avec repli historique (mode « Pour toi ») : le catalogue
* est tenté d'abord (cache 60 s, provenance) ; s'il rend moins de
* `SHORTS_CATALOG_MIN_ITEMS` items — ou en erreur — on rejoue l'appel
* historique fourni. Le reste du pipeline (filtre `accepts`, mélange,
* entrelacement) est inchangé.
*/
private catalogOrLegacy(p: Provider, topic: string, legacy: any): any {
const attempt = this.catalog.fetchCatalogShorts(p, topic, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(r) => {
const videos = (r.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
return videos.length >= SHORTS_CATALOG_MIN_ITEMS ? { items: videos, nextCursor: null } : null;
},
() => null,
).catch(() => null);
return from(attempt).pipe(
switchMap((hit) => hit
? of(hit)
: (legacy as any).pipe(catchError(() => of({ items: [], nextCursor: null })))),
);
}
/** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */
private fetchForYou(topics: string[]): void {
const picks = topics.slice(0, 4);
private fetchForYou(topics: string[]): void { const picks = topics.slice(0, 4);
if (!picks.length) {
this.afterNoResults();
this.loading.set(false);
@@ -794,11 +924,11 @@ export class WatchShortComponent implements OnDestroy {
try { return this.instances.getProviderReadiness(p).ready; } catch { return true; }
};
for (const t of picks) {
if (ready('youtube')) defs.push({ provider: 'youtube', call: this.api.searchShortsPage(t, null, 'youtube' as Provider) });
if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.api.searchShortsPage(t, null, 'dailymotion' as Provider) });
if (ready('peertube')) defs.push({ provider: 'peertube', call: this.api.searchPeerTubeShortsPage(t, null) });
if (ready('rumble')) defs.push({ provider: 'rumble', call: this.api.searchRumbleShortsPage(t, null) });
if (ready('odysee')) defs.push({ provider: 'odysee', call: this.api.searchShortsPage(t, null, 'odysee' as Provider) });
if (ready('youtube')) defs.push({ provider: 'youtube', call: this.catalogOrLegacy('youtube', t, this.api.searchShortsPage(t, null, 'youtube' as Provider)) });
if (ready('dailymotion')) defs.push({ provider: 'dailymotion', call: this.catalogOrLegacy('dailymotion', t, this.api.searchShortsPage(t, null, 'dailymotion' as Provider)) });
if (ready('peertube')) defs.push({ provider: 'peertube', call: this.catalogOrLegacy('peertube', t, this.api.searchPeerTubeShortsPage(t, null)) });
if (ready('rumble')) defs.push({ provider: 'rumble', call: this.catalogOrLegacy('rumble', t, this.api.searchRumbleShortsPage(t, null)) });
if (ready('odysee')) defs.push({ provider: 'odysee', call: this.catalogOrLegacy('odysee', t, this.api.searchShortsPage(t, null, 'odysee' as Provider)) });
}
if (ready('twitch')) {
for (const t of picks.slice(0, 2)) {
@@ -914,6 +1044,8 @@ export class WatchShortComponent implements OnDestroy {
this.seenIds = new Set<string>();
this.queryIndex = 0;
this.exhaustedQueries = new Set<string>();
this.catalogActive = false;
this.catalogError.set(null);
const ready = this.instances.getProviderReadiness(this.provider());
if (!ready.ready) {
this.loading.set(false);
@@ -921,9 +1053,11 @@ export class WatchShortComponent implements OnDestroy {
return;
}
const provider = this.provider();
// Twitch : les Clips sont l'équivalent Shorts. La recherche Helix rejette
// les requêtes vides → on part des top streams (getTwitchClipsFeed).
// Shorts via le CATALOGUE d'abord (`/api/search?type=shorts` : cache,
// provenance, feature-flags). Twitch garde son flux de clips historique
// (pas d'équivalent catalogue : la recherche serveur renvoie des VOD).
if (provider === 'twitch') {
this.catalogActive = false;
this.api.getTwitchClipsFeed()?.subscribe({
next: (res: { items: Video[]; nextCursor?: string | null }) => {
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
@@ -952,8 +1086,71 @@ export class WatchShortComponent implements OnDestroy {
});
return;
}
// Shorts via le flux dédié (filtre durée côté serveur quand possible).
// Rotation : requête courante → suivante → trending filtré (infini).
this.loadCatalogFeed();
}
/**
* Voie catalogue : une page `/api/search?type=shorts` pour la requête
* courante, mappée vers le modèle du flux puis soumise aux mêmes portes que
* l'historique (`accepts()` : classification centrale + jouabilité +
* langue, enrichissement des durées YouTube inclus). Sous le seuil
* `SHORTS_CATALOG_MIN_ITEMS` — ou en erreur — on rejoue le chemin
* historique direct : le flux ne reste jamais vide pour une raison
* d'alimentation.
*/
private loadCatalogFeed(): void {
this.catalogActive = true;
this.catalogPage = 1;
this.catalogError.set(null);
const query = this.currentQuery();
this.catalog.fetchCatalogShorts(this.provider(), query, { page: 1, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(res) => {
if (!this.catalogActive) return; // un rechargement a eu lieu entre-temps
if (res.providerError) this.catalogError.set(res.providerError);
const raw = (res.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
if (this.provider() === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(
(list) => this.showCatalogList(list.filter((v) => this.accepts(v)), query),
() => this.loadLegacyFeed(),
);
return;
}
this.showCatalogList(raw.filter((v) => this.accepts(v)), query);
},
() => this.loadLegacyFeed(),
).catch(() => this.loadLegacyFeed());
}
/** Affiche un lot catalogue (mêmes invariants que la voie historique). */
private showCatalogList(list: Video[], query: string): void {
if (!this.catalogActive) return;
if (list.length >= SHORTS_CATALOG_MIN_ITEMS) {
const deduped = this.dedup(list);
this.items.set(deduped);
this.index.set(0);
this.nextCursor.set(null);
this.error.set(null);
this.loading.set(false);
this.queueRumbleResolves(deduped);
this.ensureAvatar(deduped[0] || null);
if (deduped.length > 0 && deduped.length <= 4) this.fetchNextPage(false);
return;
}
// Lot trop maigre : la requête est marquée épuisée côté catalogue et on
// rejoue l'historique (qui a ses propres requêtes de secours + trending).
this.catalogActive = false;
this.loadLegacyFeed();
}
/**
* Chemin historique direct (requêtes `SHORTS_QUERIES` vers les API
* fournisseurs) : inchangé, il sert de repli au catalogue et reste la voie
* des cas sans équivalent catalogue.
*/
private loadLegacyFeed(): void {
this.catalogActive = false;
this.shortsPage(this.currentQuery()).subscribe({
next: (res) => this.handleShortsResponse(res, true),
error: () => this.tryNextShortsQueryOrTrending(),
@@ -1159,6 +1356,14 @@ export class WatchShortComponent implements OnDestroy {
private fetchNextPage(autoAdvance = false) {
if (this.busyMore() || this.forYou()) return;
const provider = this.provider();
// Voie catalogue : pages `/api/search` successives de la requête courante,
// puis requête suivante de la rotation. Contrairement au chemin historique
// (curseurs opaques), la pagination est un simple compteur de pages.
if (this.catalogActive && provider !== 'twitch') {
this.busyMore.set(true);
this.fetchCatalogMore(this.currentQuery(), this.catalogPage + 1, autoAdvance);
return;
}
// Cas Twitch : pagination du flux de clips (curseur streams) ou rotation.
if (provider === 'twitch') {
if (!this.nextCursor() && !this.hasMoreQueries() && this.items().length > 0) {
@@ -1212,6 +1417,57 @@ export class WatchShortComponent implements OnDestroy {
});
}
/**
* Page catalogue suivante (ajout au flux, jamais de remplacement).
* Page vide (tout est déjà vu) → requête suivante de la rotation ; rotation
* épuisée sans rien de neuf → on s'arrête proprement (pas de boucle de
* re-fetch : le cache 60 s resservirait les mêmes items, tous dédupliqués).
*/
private fetchCatalogMore(query: string, page: number, autoAdvance: boolean): void {
const provider = this.provider();
this.catalog.fetchCatalogShorts(provider, query, { page, pageSize: SHORTS_CATALOG_PAGE_SIZE }).then(
(res) => {
if (!this.catalogActive) { this.busyMore.set(false); return; }
const raw = (res.items || [])
.map((it) => catalogShortToVideo(it))
.filter((v): v is Video => !!v?.videoId);
const apply = (list: Video[]) => {
const deduped = this.dedup(list.filter((v) => this.accepts(v)));
if (deduped.length > 0) {
this.catalogPage = page;
const merged = this.items().concat(deduped);
this.items.set(merged);
this.queueRumbleResolves(deduped);
if (autoAdvance) this.index.update((i) => Math.min(i + 1, merged.length - 1));
this.busyMore.set(false);
return;
}
const rotation = this.queriesFor(provider);
if (this.queryIndex < rotation.length - 1) {
this.queryIndex++;
this.catalogPage = 1;
this.fetchCatalogMore(this.currentQuery(), 1, autoAdvance);
} else {
// Rotation complète sans rien de neuf : fin du pré-chargement,
// le flux existant reste consultable (jamais de spinner infini).
this.queryIndex = 0;
this.catalogPage = 1;
this.busyMore.set(false);
}
};
if (provider === 'youtube' && raw.length) {
this.pickYouTubeShorts(raw).then(
(list) => apply(list),
() => { this.busyMore.set(false); },
);
} else {
apply(raw);
}
},
() => { this.busyMore.set(false); },
).catch(() => { this.busyMore.set(false); });
}
/** Passe à la requête Twitch suivante (clips thématiques). */
private rotateTwitchQuery(autoAdvance = false): void {
const list = this.queriesFor('twitch');