feat(shorts): page /shorts alimentee par le catalogue + refonte pro/mobile
CI / build-and-test (push) Successful in 14m45s
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:
@@ -97,6 +97,9 @@ jobs:
|
||||
- name: Unified ProviderAdapter contract (offline)
|
||||
run: npm run test:adapter
|
||||
|
||||
- name: Shorts catalogue bridge (offline)
|
||||
run: npm run test:shorts-catalog
|
||||
|
||||
- name: Contract v2 strict + `FF_<PROVIDER>` feature flags (offline)
|
||||
run: npm run test:flags
|
||||
|
||||
|
||||
@@ -476,6 +476,14 @@ La table est **additive**. Le Phase 5.3 (LEFT JOIN) est le seul changement de le
|
||||
- [x] Un provider en échec affiche un bandeau ; un provider réellement vide affiche un état vide neutre.
|
||||
- [x] Aucun onglet d'onglet vide n'est proposé sur les pages chaîne. *(`canShowPill()`)*
|
||||
|
||||
### 9.9 Shorts-catalogue — la page `/shorts` alimentée par le catalogue (hors plan initial)
|
||||
|
||||
- [x] `SearchService.fetchCatalogShorts(providerLong, q, { page, pageSize })` : un provider, filtre `type: 'shorts'` (serveur quand il sait, `matchesFilters` sinon), via `searchAdapter` (cache 60 s, timeout 8 s, 1 retry, erreurs avalées) — le flux ne casse jamais sur un provider lent ou éteint (`providerError` → pastille « Source dégradée »).
|
||||
- [x] `WatchShortComponent` : catalogue d'abord (feed, pagination par pages, mode « Pour toi » via `catalogOrLegacy`), repli historique direct sous `SHORTS_CATALOG_MIN_ITEMS` (= 3) ou en erreur ; Twitch garde ses clips (pas d'équivalent catalogue) ; enrichissement des durées YouTube et portes `accepts()` inchangés.
|
||||
- [x] Pont pur `src/app/search/shorts-catalog.ts` (`catalogShortToVideo`, `formatShortMeta`, `formatCapturedAgo`) + spec hors-ligne `npm run test:shorts-catalog` (27 assertions : pas d'id fantôme, pas de fraîcheur inventée, pas de « 0 vue »).
|
||||
- [x] Avatars paresseux via `channelMeta` (`GET /channels/:provider/:externalId`, une requête par vidéo, silence en échec) + initiale de repli — plus d'image cassée quand la recherche ne fournit pas d'avatar.
|
||||
- [x] Refonte pro façon YouTube + mobile : scène plein écran (`100dvh`, fond d'ambiance flouté), rail d'actions **overlay dans le cadre** (J'aime / Commentaires / Partager / Son, cibles 48 px), méta sous le titre (« 12 k vues • il y a 2 j » + « capturé il y a 4 min • api »), pastille provider, safe-area, `touch-action: pan-y`, cadre sans radius sur mobile.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 8 — Architecture de fond
|
||||
|
||||
@@ -506,6 +506,46 @@ body {
|
||||
--shorts-chrome: 9rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile : cadre quasi plein écran (seuls les chips + marges sont réservés). */
|
||||
.shorts-frame {
|
||||
--shorts-chrome: 8.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Shorts immersive stage (façon YouTube) -------------------------------- */
|
||||
/* Plein écran vertical : le cadre 9:16 flotte sur un fond d'ambiance tiré de
|
||||
la vignette courante. `100dvh` (barres navigateur mobiles) avec repli. */
|
||||
.shorts-stage {
|
||||
min-height: calc(var(--app-vh, 100dvh) - var(--header-offset, 0px));
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.shorts-ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.shorts-ambient img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(48px) brightness(0.45);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.shorts-ambient img {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
}
|
||||
/* Zone de méta basse : la safe-area des encoches ne doit jamais manger le
|
||||
titre ou le bouton S'abonner. */
|
||||
.shorts-meta {
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* --- Drawer / overlay plumbing -------------------------------------------- */
|
||||
/* Applied to <body> while a full-screen drawer or modal owns the viewport.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"test:provenance": "node --test ./server/tests/provenance.test.mjs ./server/tests/search_debug.test.mjs",
|
||||
"test:stream": "node --test ./server/tests/search_stream.test.mjs ./server/tests/search_stream_provider.test.mjs",
|
||||
"test:adapter": "node --test ./server/tests/adapter_contract.test.mjs",
|
||||
"test:shorts-catalog": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/shorts-catalog.spec.ts",
|
||||
"test:provider-health": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/provider-health.service.spec.ts",
|
||||
"test:provenance-front": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/adapters/provenance.spec.ts",
|
||||
"test:transcript": "node --test server/tests/transcript.test.mjs",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
|
||||
import { mergeMap, scan, take } from 'rxjs/operators';
|
||||
import { ALL_PROVIDER_IDS, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import { ALL_PROVIDER_IDS, toShortProviderId, type ProviderId } from '../shared/providers/provider-ids';
|
||||
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
|
||||
import { YtAdapter } from './adapters/yt';
|
||||
import { DmAdapter } from './adapters/dm';
|
||||
@@ -12,7 +12,7 @@ import { OdAdapter } from './adapters/od';
|
||||
import { RuAdapter } from './adapters/ru';
|
||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
import { DEFAULT_FILTERS, activeFilters, matchesFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||
|
||||
export type SuggestionItem = SuggestionItemV1;
|
||||
export type SearchResponse = SearchResponseV1 & { partial?: boolean };
|
||||
@@ -178,4 +178,41 @@ export class SearchService {
|
||||
}
|
||||
/** Filtres actifs seulement (pour l'URL / le partage). */
|
||||
activeFilters(): Partial<SearchFilters> { return activeFilters(this.filters$.value); }
|
||||
|
||||
/**
|
||||
* Phase Shorts-catalogue — la page `/shorts` alimentée par le catalogue.
|
||||
*
|
||||
* Un provider (nom long : `youtube`, `dailymotion`…), une requête, filtre
|
||||
* `type: 'shorts'` : appliqué côté serveur quand le provider le sait
|
||||
* (`withFilterParams` → `/api/search`), affiné ici dans tous les cas
|
||||
* (`matchesFilters`, même doctrine que la page de recherche). Réutilise
|
||||
* `searchAdapter` : cache 60 s, timeout 8 s, 1 retry, erreurs avalées —
|
||||
* le flux Shorts ne casse donc jamais sur un provider lent ou éteint
|
||||
* (`providerError` remonte pour le bandeau « source dégradée »).
|
||||
*
|
||||
* Les providers sans équivalent Shorts catalogue (ex. Twitch : le flux
|
||||
* historique utilise les *clips*, pas les VOD) renvoient `[]` ici : c'est
|
||||
* le signal pour l'appelant de rejouer son chemin historique.
|
||||
*/
|
||||
async fetchCatalogShorts(
|
||||
providerLong: string,
|
||||
q: string,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
): Promise<{ items: VideoItem[]; providerError?: string }> {
|
||||
const pid = toShortProviderId(providerLong);
|
||||
const query = String(q || '').trim();
|
||||
if (!pid || !query) return { items: [] };
|
||||
const params: ProviderSearchParams = {
|
||||
q: query,
|
||||
pageToken: String(Math.max(1, Math.floor(opts?.page || 1))),
|
||||
sort: 'relevance',
|
||||
filters: normalizeFilters({ type: 'shorts' }),
|
||||
};
|
||||
const res = await this.searchAdapter(pid, params);
|
||||
const pageSize = Math.min(50, Math.max(1, Math.floor(opts?.pageSize || 25)));
|
||||
const items = (res.items || [])
|
||||
.filter((it) => matchesFilters(it, { type: 'shorts' }))
|
||||
.slice(0, pageSize);
|
||||
return { items, ...(res.providerError ? { providerError: res.providerError } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
catalogShortToVideo,
|
||||
formatShortMeta,
|
||||
formatCapturedAgo,
|
||||
formatCompactFr,
|
||||
SHORTS_CATALOG_PAGE_SIZE,
|
||||
SHORTS_CATALOG_MIN_ITEMS,
|
||||
} from './shorts-catalog';
|
||||
|
||||
/**
|
||||
* Phase Shorts-catalogue — pont pur catalogue → flux Shorts.
|
||||
*
|
||||
* Miroir offline du câblage `SearchService.fetchCatalogShorts` +
|
||||
* `WatchShortComponent` : chaque règle d'honnêteté du mapping est figée ici
|
||||
* (pas d'id inventé, pas de fraîcheur inventée, pas de « 0 vue »).
|
||||
*
|
||||
* Run with: npm run test:shorts-catalog
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`ok: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = 1700000000000;
|
||||
|
||||
console.log('--- catalogShortToVideo ---');
|
||||
assertEqual(catalogShortToVideo(null), null, 'null → null');
|
||||
assertEqual(catalogShortToVideo({} as any), null, 'sans id → null (pas de fantôme)');
|
||||
assertEqual(
|
||||
catalogShortToVideo({ id: 'x1', provider: 'youtube', title: 'T', thumbnailUrl: 'th', channelName: 'C' } as any)?.videoId,
|
||||
'x1',
|
||||
'id → videoId',
|
||||
);
|
||||
{
|
||||
const v: any = catalogShortToVideo({
|
||||
id: 'o1', provider: 'odysee', title: 'O', thumbnailUrl: '', channelName: '@chan',
|
||||
slug: '@chan/video-1', capturedAt: NOW - 240000, source: 'api',
|
||||
} as any);
|
||||
assertEqual(v?.url, 'https://odysee.com/@chan/video-1', 'slug Odysee → url embeddable');
|
||||
assertEqual(v?.raw, { slug: '@chan/video-1', capturedAt: NOW - 240000, source: 'api' }, 'provenance conservée dans raw');
|
||||
assertEqual(v?.views, 0, 'vues inconnues → 0 (pas de devinette)');
|
||||
assertEqual(v?.duration, 0, 'durée inconnue → 0');
|
||||
}
|
||||
{
|
||||
const v: any = catalogShortToVideo({
|
||||
id: 'y1', provider: 'youtube', title: 'Y', thumbnailUrl: '', channelName: 'C',
|
||||
durationSec: 42, viewCount: 12345, publishedAt: '2023-01-01T00:00:00.000Z',
|
||||
isShort: true, width: 720, height: 1280, language: 'fr',
|
||||
} as any);
|
||||
assertEqual(v?.type, 'short', 'flag isShort → type short');
|
||||
assertEqual(v?.duration, 42, 'durationSec propagée');
|
||||
assertEqual(v?.views, 12345, 'viewCount propagé');
|
||||
assertEqual(v?.language, 'fr', 'langue propagée (filtre FR/EN du flux)');
|
||||
assertEqual(v?.raw, undefined, 'sans slug/provenance → raw absent');
|
||||
}
|
||||
{
|
||||
const v: any = catalogShortToVideo({ id: 't1', provider: 'twitch', title: 'L', thumbnailUrl: '', channelName: 'C', isLive: true } as any);
|
||||
assertEqual(v?.type, 'live', 'live prime sur le reste');
|
||||
}
|
||||
|
||||
console.log('--- formatShortMeta ---');
|
||||
assertEqual(formatShortMeta(undefined, undefined, NOW), '', 'rien connu → rien affiché');
|
||||
assertEqual(formatShortMeta(0, undefined, NOW), '', '0 vue → omis (pas « 0 vue »)');
|
||||
assertEqual(formatShortMeta(12345, undefined, NOW).includes('vues'), true, 'vues seules');
|
||||
assertEqual(formatShortMeta(undefined, new Date(NOW - 2 * 86400000).toISOString(), NOW), 'il y a 2 j', 'âge seul');
|
||||
assertEqual(
|
||||
formatShortMeta(12345, new Date(NOW - 2 * 86400000).toISOString(), NOW).includes('•'),
|
||||
true,
|
||||
'vues + âge joints par •',
|
||||
);
|
||||
|
||||
console.log('--- formatCapturedAgo ---');
|
||||
assertEqual(formatCapturedAgo(undefined, NOW), '', 'capturedAt absent → pas de badge');
|
||||
assertEqual(formatCapturedAgo(0, NOW), '', 'capturedAt 0 → pas de badge');
|
||||
assertEqual(formatCapturedAgo(-5, NOW), '', 'capturedAt négatif → pas de badge');
|
||||
assertEqual(formatCapturedAgo(NOW - 30000, NOW), 'capturé à l’instant', 'frais → à l’instant');
|
||||
assertEqual(formatCapturedAgo(NOW - 4 * 60000, NOW), 'capturé il y a 4 min', 'minutes');
|
||||
assertEqual(formatCapturedAgo(NOW + 60000, NOW), 'capturé à l’instant', 'futur → à l’instant (jamais « dans 1 min »)');
|
||||
|
||||
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');
|
||||
assertEqual(formatCompactFr(12345).length > 0, true, 'compact fr non vide');
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { VideoItem } from '../shared/models/video-item.model';
|
||||
import type { Video } from '../../models/video.model';
|
||||
|
||||
/**
|
||||
* Adaptation de la page Shorts au catalogue (phase Shorts-catalogue).
|
||||
*
|
||||
* La page `/shorts` interrogeait chaque fournisseur en direct depuis le
|
||||
* navigateur (clés YouTube côté client, scrapers ad hoc) : ni cache, ni
|
||||
* provenance, ni feature-flags, ni capacités. Ce module est le pont PUR
|
||||
* (aucune dépendance Angular : testable hors-ligne) entre le catalogue unifié
|
||||
* (`SearchService.fetchCatalogShorts`, qui passe par les 6 adaptateurs vers
|
||||
* `/api/search?type=shorts`) et le modèle historique du flux (`Video`).
|
||||
*
|
||||
* Règles :
|
||||
* - métadonnée absente = absente (jamais de `Date.now()` inventé, même
|
||||
* doctrine que la phase 7.3) ;
|
||||
* - la provenance (`capturedAt`/`source`) voyage dans `raw` pour l'affichage
|
||||
* de fraîcheur, sans polluer le modèle historique ;
|
||||
* - `null` si l'item n'a pas d'id (jamais d'entrée fantôme dans le flux).
|
||||
*/
|
||||
|
||||
/** Taille d'une page catalogue pour le flux (le serveur plafonne à 50). */
|
||||
export const SHORTS_CATALOG_PAGE_SIZE = 25;
|
||||
|
||||
/** Seuil catalogue→repli : en dessous, on rejoue le chemin historique direct. */
|
||||
export const SHORTS_CATALOG_MIN_ITEMS = 3;
|
||||
|
||||
function numOrUndef(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function strOrEmpty(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/** `VideoItem` (catalogue) → `Video` (flux Shorts historique). */
|
||||
export function catalogShortToVideo(item: VideoItem | null | undefined): Video | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const id = strOrEmpty((item as { id?: unknown }).id).trim();
|
||||
if (!id) return null;
|
||||
const provider = strOrEmpty((item as { provider?: unknown }).provider) as Video['provider'];
|
||||
const publishedAt = strOrEmpty(item.publishedAt);
|
||||
const uploaded = publishedAt ? Date.parse(publishedAt) : NaN;
|
||||
const slug = strOrEmpty((item as { slug?: unknown }).slug);
|
||||
const capturedAt = numOrUndef((item as { capturedAt?: unknown }).capturedAt);
|
||||
const sourceRaw = (item as { source?: unknown }).source;
|
||||
const source = typeof sourceRaw === 'string' && sourceRaw.trim() ? sourceRaw.trim() : undefined;
|
||||
const kindRaw = (item as { kind?: unknown }).kind;
|
||||
const kind = kindRaw === 'clip' || kindRaw === 'vod' || kindRaw === 'short' || kindRaw === 'channel'
|
||||
? kindRaw
|
||||
: undefined;
|
||||
return {
|
||||
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '',
|
||||
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
||||
title: strOrEmpty(item.title),
|
||||
thumbnail: strOrEmpty(item.thumbnailUrl),
|
||||
uploaderName: strOrEmpty(item.channelName),
|
||||
uploaderAvatar: strOrEmpty(item.uploaderAvatar) || strOrEmpty(item.channelAvatarUrl),
|
||||
channelExternalId: strOrEmpty(item.channelExternalId) || undefined,
|
||||
uploadedDate: publishedAt,
|
||||
duration: typeof item.durationSec === 'number' && Number.isFinite(item.durationSec) ? item.durationSec : 0,
|
||||
views: typeof item.viewCount === 'number' && Number.isFinite(item.viewCount) ? item.viewCount : 0,
|
||||
uploaded: Number.isFinite(uploaded) ? uploaded : 0,
|
||||
videoId: id,
|
||||
provider,
|
||||
width: typeof item.width === 'number' ? item.width : null,
|
||||
height: typeof item.height === 'number' ? item.height : null,
|
||||
isShort: item.isShort === true ? true : undefined,
|
||||
isLive: item.isLive === true ? true : undefined,
|
||||
kind,
|
||||
language: typeof item.language === 'string' ? item.language : undefined,
|
||||
// Provenance catalogue : le flux l'affiche (« capturé il y a X »), le
|
||||
// modèle historique n'a pas ces champs → repli `raw`, jamais de perte.
|
||||
raw: (slug || capturedAt !== undefined || source)
|
||||
? { ...(slug ? { slug } : {}), ...(capturedAt !== undefined ? { capturedAt } : {}), ...(source ? { source } : {}) }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nombre compact fr (« 12 k », « 3,4 M ») — `Intl` plutôt qu'un format maison. */
|
||||
export function formatCompactFr(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '';
|
||||
try {
|
||||
return new Intl.NumberFormat('fr-FR', { notation: 'compact', maximumFractionDigits: 1 }).format(n);
|
||||
} catch {
|
||||
return String(Math.round(n));
|
||||
}
|
||||
}
|
||||
|
||||
/** Âge relatif court fr (« à l'instant », « il y a 4 min », « il y a 2 j »). */
|
||||
export function formatAgeFr(ts: number, now: number = Date.now()): string {
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
const diff = Math.max(0, now - ts);
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return "à l'instant";
|
||||
if (min < 60) return `il y a ${min} min`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `il y a ${h} h`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `il y a ${d} j`;
|
||||
if (d < 30) return `il y a ${Math.floor(d / 7)} sem.`;
|
||||
if (d < 365) return `il y a ${Math.floor(d / 30)} mois`;
|
||||
return `il y a ${Math.floor(d / 365)} an${Math.floor(d / 365) > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ligne de méta sous le titre façon YouTube (« 12 k vues • il y a 2 j »).
|
||||
* Chaque morceau inconnu est omis, jamais deviné — une chaîne sans vues
|
||||
* connues affiche seulement son âge, pas « 0 vue ».
|
||||
*/
|
||||
export function formatShortMeta(views?: number, publishedAt?: string, now: number = Date.now()): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof views === 'number' && Number.isFinite(views) && views > 0) {
|
||||
parts.push(`${formatCompactFr(views)} vues`);
|
||||
}
|
||||
if (typeof publishedAt === 'string' && publishedAt.trim()) {
|
||||
const ts = Date.parse(publishedAt);
|
||||
const age = formatAgeFr(ts, now);
|
||||
if (age) parts.push(age);
|
||||
}
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraîcheur de collecte catalogue (« capturé il y a 4 min »).
|
||||
* `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 '';
|
||||
const at = Number(capturedAt);
|
||||
if (at > now) return 'capturé à l’instant';
|
||||
const min = Math.floor((now - at) / 60000);
|
||||
if (min < 1) return 'capturé à l’instant';
|
||||
if (min < 60) return `capturé il y a ${min} min`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `capturé il y a ${h} h`;
|
||||
return `capturé il y a ${Math.floor(h / 24)} j`;
|
||||
}
|
||||
@@ -1,103 +1,164 @@
|
||||
<div class="shorts-stage flex w-full flex-col items-center gap-3 rounded-2xl bg-black/60 p-2 sm:p-4" (click)="closeMenu()">
|
||||
<div class="shorts-stage relative flex min-h-[calc(var(--app-vh,100dvh)-var(--header-offset,0px))] w-full flex-col items-center overflow-hidden bg-black" (click)="closeMenu()">
|
||||
<!-- Fond d'ambiance : vignette du short courant, floutée (façon YouTube). -->
|
||||
<div class="shorts-ambient" aria-hidden="true">
|
||||
@if (current()?.thumbnail) {
|
||||
<img [src]="current()?.thumbnail" alt="" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sélecteur : Pour toi + fournisseurs -->
|
||||
<div class="scroll-rail no-scrollbar flex max-w-full items-center justify-start gap-2 sm:justify-center" role="tablist" aria-label="Fournisseur des Shorts" (click)="$event.stopPropagation()">
|
||||
<div class="scroll-rail no-scrollbar 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-sm border transition-colors"
|
||||
[class]="forYou() ? 'bg-violet-600 border-violet-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'"
|
||||
class="chip shrink-0 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
|
||||
</button>
|
||||
<button *ngFor="let p of providersList()"
|
||||
(click)="selectProvider(p.id)"
|
||||
[attr.aria-pressed]="!forYou() && provider() === p.id"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
[class]="!forYou() && provider() === p.id ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
class="chip shrink-0 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'">
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scène immersive : flèches + cadre 9:16 + rail d'actions -->
|
||||
<div class="flex w-full min-h-0 items-center justify-center gap-1 sm:gap-3"
|
||||
<!-- Scène immersive : flèches (bureau) + cadre 9:16 -->
|
||||
<div class="relative z-10 flex w-full min-h-0 flex-1 items-center justify-center gap-1 px-0 py-3 sm:gap-3 sm:px-4"
|
||||
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
<!-- Flèche précédent (bureau, façon YouTube) -->
|
||||
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
|
||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
|
||||
aria-label="Short précédent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 14l5-5 5 5H7z"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Center column : cadre 9:16 + rail d'actions -->
|
||||
<div class="relative flex w-full min-w-0 items-end sm:items-center justify-center gap-2 sm:gap-3" style="max-width: 560px;">
|
||||
<!-- Colonne centrale : cadre 9:16 (sizing dans index.css : .shorts-frame) -->
|
||||
<div class="relative flex min-w-0 items-center justify-center" style="max-width: 560px;">
|
||||
<!-- Video frame (9:16) -->
|
||||
<div #shortsFrame class="shorts-frame relative shrink-0 rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black">
|
||||
<div #shortsFrame class="shorts-frame relative shrink-0 overflow-hidden bg-black rounded-none ring-0 shadow-none sm:rounded-2xl sm:ring-1 sm:ring-white/10 sm:shadow-2xl">
|
||||
@if (embedUrl(); as url) {
|
||||
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" frameborder="0" playsinline
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
||||
} @else if (loading()) {
|
||||
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-slate-900 text-slate-400 text-sm">
|
||||
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-black text-slate-400 text-sm">
|
||||
<div class="h-8 w-8 rounded-full border-2 border-slate-600 border-t-red-500 animate-spin" aria-hidden="true"></div>
|
||||
<span role="status">Chargement des Shorts…</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center text-slate-300 p-6 text-center">
|
||||
<span>{{ error() }}</span>
|
||||
<button (click)="retry(); $event.stopPropagation()" class="chip px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
|
||||
<button (click)="retry(); $event.stopPropagation()" class="chip px-4 py-3 rounded bg-white/10 hover:bg-white/20 text-white text-sm min-h-[44px]">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (current(); as v) {
|
||||
<!-- Haut droite : plein écran, partage rapide, menu ⋯ -->
|
||||
<div class="absolute top-2 right-2 flex items-center gap-1.5" (click)="$event.stopPropagation()">
|
||||
<button type="button" (click)="toggleFullscreen()" class="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" [title]="isFullscreen() ? 'Quitter le plein écran' : 'Plein écran'" aria-label="Plein écran">
|
||||
<button type="button" (click)="toggleFullscreen()" class="flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" [title]="isFullscreen() ? 'Quitter le plein écran' : 'Plein écran'" aria-label="Plein écran">
|
||||
@if (isFullscreen()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
}
|
||||
</button>
|
||||
<button type="button" (click)="share()" class="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" title="Partager" aria-label="Partager">
|
||||
<button type="button" (click)="share()" class="flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" title="Partager" aria-label="Partager">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button type="button" (click)="toggleMenu(); $event.stopPropagation()" class="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" title="Plus d'options" aria-label="Plus d'options" aria-haspopup="menu">
|
||||
<button type="button" (click)="toggleMenu(); $event.stopPropagation()" class="flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" title="Plus d'options" aria-label="Plus d'options" aria-haspopup="menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
@if (menuOpen()) {
|
||||
<div class="absolute right-0 top-10 z-20 w-56 overflow-hidden rounded-xl bg-slate-900/95 py-1 shadow-2xl ring-1 ring-slate-700" role="menu" (click)="$event.stopPropagation()">
|
||||
<a *ngIf="providerUrl()" [href]="providerUrl()" target="_blank" rel="noopener" (click)="closeMenu()" class="block px-4 py-2.5 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">Ouvrir chez le fournisseur</a>
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="closeMenu()" class="block px-4 py-2.5 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">Ouvrir dans Watch</a>
|
||||
<button type="button" (click)="copyLink()" class="block w-full px-4 py-2.5 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">{{ shareCopied() ? 'Lien copié ✓' : 'Copier le lien' }}</button>
|
||||
<button type="button" (click)="skipUnplayable(); closeMenu()" class="block w-full px-4 py-2.5 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">⏭ Passer (vidéo illisible)</button>
|
||||
<div class="absolute right-0 top-11 z-20 w-60 overflow-hidden rounded-xl bg-slate-900/95 py-1 shadow-2xl ring-1 ring-white/10" role="menu" (click)="$event.stopPropagation()">
|
||||
<a *ngIf="providerUrl()" [href]="providerUrl()" target="_blank" rel="noopener" (click)="closeMenu()" class="block px-4 py-3 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">Ouvrir chez le fournisseur</a>
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="closeMenu()" class="block px-4 py-3 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">Ouvrir dans Watch</a>
|
||||
<button type="button" (click)="copyLink()" class="block w-full px-4 py-3 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">{{ shareCopied() ? 'Lien copié ✓' : 'Copier le lien' }}</button>
|
||||
@if (currentSource()) {
|
||||
<div class="px-4 py-2 text-left text-[11px] text-slate-400" role="note">Via le catalogue • {{ currentSource() }}</div>
|
||||
}
|
||||
<button type="button" (click)="skipUnplayable(); closeMenu()" class="block w-full px-4 py-3 text-left text-sm text-slate-200 hover:bg-slate-800" role="menuitem">⏭ Passer (vidéo illisible)</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bas droite : sourdine -->
|
||||
<div class="absolute bottom-14 right-2" (click)="$event.stopPropagation()">
|
||||
<button type="button" (click)="toggleMute()" class="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80" [title]="muted() ? 'Activer le son (reprend au début)' : 'Couper le son (reprend au début)'" aria-label="Activer ou couper le son">
|
||||
@if (muted()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="17" y1="9" x2="23" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
||||
}
|
||||
</button>
|
||||
<!-- Rail d'actions OVERLAY façon YouTube (dans le cadre, comme sur mobile natif) -->
|
||||
<div class="absolute right-1.5 sm:right-2.5 bottom-32 sm:bottom-36 flex flex-col items-center gap-2 sm:gap-2.5" (click)="$event.stopPropagation()">
|
||||
<div class="flex flex-col items-center">
|
||||
<button type="button" (click)="toggleLike()" [disabled]="!isLoggedIn() || likeBusy()"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75 disabled:opacity-40"
|
||||
[title]="isLoggedIn() ? (liked() ? 'Je n’aime plus' : 'J’aime') : 'Connectez-vous pour aimer'" aria-label="J’aime">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 drop-shadow" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z"/></svg>
|
||||
</button>
|
||||
<span class="mt-0.5 text-[11px] font-medium text-white drop-shadow">J'aime</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75"
|
||||
title="Voir les commentaires dans Watch" aria-label="Commentaires">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 drop-shadow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</a>
|
||||
<span class="mt-0.5 text-[11px] font-medium text-white drop-shadow">Commentaires</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<button type="button" (click)="share()"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75"
|
||||
title="Partager" aria-label="Partager">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 drop-shadow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>
|
||||
</button>
|
||||
<span class="mt-0.5 text-[11px] font-medium text-white drop-shadow">Partager</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<button type="button" (click)="toggleMute()"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75"
|
||||
[title]="muted() ? 'Activer le son' : 'Couper le son'" aria-label="Activer ou couper le son">
|
||||
@if (muted()) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="17" y1="9" x2="23" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 drop-shadow" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
||||
}
|
||||
</button>
|
||||
<span class="mt-0.5 text-[11px] font-medium text-white drop-shadow">Son</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bas gauche : titre + chaîne + S'abonner (pointer-events ciblés) -->
|
||||
<div class="pointer-events-none absolute left-0 right-0 bottom-0 p-3 sm:p-4 pb-2.5 bg-gradient-to-t from-black/80 via-black/30 to-transparent text-white">
|
||||
<div class="flex items-end gap-3 text-sm" (click)="$event.stopPropagation()">
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-9 w-9 shrink-0 rounded-full object-cover ring-1 ring-white/30">
|
||||
<!-- Bas : avatar + titre + méta + fraîcheur + S'abonner (safe-area incluse) -->
|
||||
<div class="shorts-meta pointer-events-none absolute left-0 right-0 bottom-0 p-3 sm:p-4 text-white bg-gradient-to-t from-black/85 via-black/35 to-transparent">
|
||||
<div class="flex items-end gap-2.5 text-sm" (click)="$event.stopPropagation()">
|
||||
@if (v.uploaderAvatar) {
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-white/40" loading="lazy" />
|
||||
} @else {
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/20 text-base font-bold ring-2 ring-white/40" aria-hidden="true">{{ (v.uploaderName || '?').trim().charAt(0).toUpperCase() }}</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold line-clamp-2 leading-snug">{{ v.title }}</div>
|
||||
<div class="opacity-80 line-clamp-1 text-[13px]">@{{ v.uploaderName }}</div>
|
||||
<div class="font-semibold line-clamp-2 leading-snug text-[15px] drop-shadow">{{ v.title }}</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1.5 text-[13px] text-white/85">
|
||||
<span class="truncate">@{{ v.uploaderName }}</span>
|
||||
<span class="shrink-0 rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-semibold">{{ providerLabel() }}</span>
|
||||
</div>
|
||||
@if (currentMeta()) {
|
||||
<div class="mt-0.5 text-[12px] text-white/70">{{ currentMeta() }}</div>
|
||||
}
|
||||
@if (currentCaptured() || catalogError()) {
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
@if (currentCaptured()) {
|
||||
<span class="rounded bg-white/10 px-1.5 py-0.5 text-white/70">{{ currentCaptured() }}{{ currentSource() ? ' • ' + currentSource() : '' }}</span>
|
||||
} @else if (currentSource()) {
|
||||
<span class="rounded bg-white/10 px-1.5 py-0.5 text-white/70">Source : {{ currentSource() }}</span>
|
||||
}
|
||||
@if (catalogError()) {
|
||||
<span class="rounded bg-amber-500/25 px-1.5 py-0.5 font-semibold text-amber-200" [title]="catalogError() || ''">Source dégradée</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isLoggedIn() && currentChannelKey()) {
|
||||
<button type="button" (click)="toggleSubscribe()" [disabled]="subBusy()"
|
||||
class="chip pointer-events-auto shrink-0 px-4 py-2 rounded-full text-[13px] font-semibold transition-colors disabled:opacity-50"
|
||||
[class]="isSubscribed() ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-white text-black hover:bg-slate-200'">
|
||||
class="chip pointer-events-auto shrink-0 px-4 py-2.5 rounded-full text-[13px] font-semibold transition-colors disabled:opacity-50 min-h-[44px]"
|
||||
[class]="isSubscribed() ? 'bg-white/15 text-white hover:bg-white/25' : 'bg-white text-black hover:bg-slate-200'">
|
||||
{{ isSubscribed() ? 'Abonné' : "S'abonner" }}
|
||||
</button>
|
||||
}
|
||||
@@ -115,46 +176,18 @@
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-black/70 text-white text-xs" role="status">Lien copié ✓</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Rail d'interaction (façon YouTube) -->
|
||||
<div class="flex shrink-0 flex-col items-center gap-2 sm:gap-3 pb-1" (click)="$event.stopPropagation()">
|
||||
<div class="flex flex-col items-center">
|
||||
<button type="button" (click)="toggleLike()" [disabled]="!isLoggedIn() || likeBusy()"
|
||||
class="flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-slate-800/90 text-white hover:bg-slate-700 disabled:opacity-40"
|
||||
[title]="isLoggedIn() ? (liked() ? 'Je n’aime plus' : 'J’aime') : 'Connectez-vous pour aimer'" aria-label="J’aime">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z"/></svg>
|
||||
</button>
|
||||
<span class="mt-0.5 text-[11px] text-slate-300">J'aime</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
|
||||
class="flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-slate-800/90 text-white hover:bg-slate-700"
|
||||
title="Voir les commentaires dans Watch" aria-label="Commentaires">
|
||||
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</a>
|
||||
<span class="mt-0.5 text-[11px] text-slate-300">Commentaires</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<button type="button" (click)="share()"
|
||||
class="flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-slate-800/90 text-white hover:bg-slate-700"
|
||||
title="Partager" aria-label="Partager">
|
||||
<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"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>
|
||||
</button>
|
||||
<span class="mt-0.5 text-[11px] text-slate-300">Partager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flèche suivant (bureau, façon YouTube) -->
|
||||
<button type="button" (click)="next(); $event.stopPropagation()" [disabled]="!canNext()"
|
||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700 disabled:opacity-30"
|
||||
class="chip hidden sm:flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20 disabled:opacity-30"
|
||||
aria-label="Short suivant">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (busyMore()) {
|
||||
<div class="px-3 py-1.5 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
|
||||
<div class="relative z-10 px-3 py-1.5 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
|
||||
}
|
||||
<!-- Flux infini : aucune pagination visible (compteur et boutons Préc./Suiv.
|
||||
retirés) — molette, flèches ↑ ↓, balayage ou flèches latérales (bureau). -->
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user