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:
@@ -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