fix(twitch,rumble,shorts): replis serveur, embed Rumble autoritaire, refonte UX Shorts
CI / build-and-test (push) Successful in 14m15s
CI / build-and-test (push) Successful in 14m15s
This commit is contained in:
@@ -3014,6 +3014,19 @@ app.get('/api/twitch-token', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Top streams Twitch via le token serveur (repli fiable pour Home/Explore
|
||||
// quand les appels Helix directs du navigateur échouent).
|
||||
app.get('/api/twitch/top-streams', async (req, res) => {
|
||||
try {
|
||||
const first = Math.min(50, Math.max(1, parseInt(String(req.query.first || '24'), 10) || 24));
|
||||
const { default: twitch } = await import('./providers/twitch.mjs');
|
||||
const items = await twitch.topStreams(first);
|
||||
return res.json({ items });
|
||||
} catch (e) {
|
||||
return res.status(502).json({ items: [], error: e?.message || 'twitch_top_streams_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------- Gemini summarize (server-side) --------------------
|
||||
// Keeps GEMINI_API_KEY on the server. Client calls POST /api/ai/summarize.
|
||||
app.get('/api/ai/status', (_req, res) => {
|
||||
|
||||
@@ -302,6 +302,24 @@ async function searchChannelsMulti(queries, first, auth, after = '') {
|
||||
const handler = {
|
||||
id: 'tw',
|
||||
label: 'Twitch',
|
||||
/**
|
||||
* Top streams via le token serveur (retries inclus).
|
||||
* Utilisé par la route /api/twitch/top-streams : repli fiable quand les
|
||||
* appels Helix directs du navigateur échouent (proxy, CORS, 429...).
|
||||
* @param {number} limit
|
||||
*/
|
||||
async topStreams(limit = 24) {
|
||||
try {
|
||||
const appToken = await getAppAccessToken();
|
||||
const auth = { token: appToken.token, clientId: appToken.clientId };
|
||||
const first = String(Math.min(100, Math.max(1, Number(limit) || 24)));
|
||||
const data = await helixGet('/streams', new URLSearchParams({ first }), auth, { retries: 2 });
|
||||
return Array.isArray(data?.data) ? data.data.map(mapStream) : [];
|
||||
} catch (e) {
|
||||
console.error('Twitch topStreams error:', e?.message || e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Recherche mixte : lives + VODs + clips + chaînes.
|
||||
* @param {string} q
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="flex w-full flex-col items-center gap-3 p-2 sm:p-4">
|
||||
<!-- Sélecteur de fournisseur : la page n'est plus bloquée sur un seul provider -->
|
||||
<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">
|
||||
<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()">
|
||||
<!-- 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()">
|
||||
<button (click)="toggleForYou()"
|
||||
[attr.aria-pressed]="forYou()"
|
||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm border transition-colors"
|
||||
@@ -10,84 +10,159 @@
|
||||
</button>
|
||||
<button *ngFor="let p of providersList()"
|
||||
(click)="selectProvider(p.id)"
|
||||
[attr.aria-pressed]="provider() === 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]="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]="!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'">
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Player area: height is derived from dvh *and* vw so a 9:16 frame never
|
||||
overflows either a portrait phone or a landscape one. -->
|
||||
<div class="flex w-full min-h-0 items-center justify-center gap-2 sm:gap-3"
|
||||
<!-- 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"
|
||||
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchend)="onTouchEnd($event)">
|
||||
<!-- Center column : cadre 9:16 + rail d'actions (jamais en dehors de l'écran) -->
|
||||
<div class="relative flex w-full min-w-0 items-end sm:items-center justify-center gap-2 sm:gap-3">
|
||||
<!-- Video frame (9:16) -->
|
||||
<div class="shorts-frame relative shrink-0 rounded-xl overflow-hidden shadow-2xl ring-1 ring-slate-700/50 bg-black/90">
|
||||
@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-800 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()" class="chip px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-white text-sm">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bottom overlay: meta. pointer-events-none pour ne jamais bloquer
|
||||
les boutons du lecteur (play / volume / plein écran). Seul le
|
||||
lien "Ouvrir" reste cliquable. -->
|
||||
@if (current(); as v) {
|
||||
<div class="pointer-events-none absolute left-0 right-0 bottom-0 p-3 sm:p-4 bg-gradient-to-t from-black/70 to-transparent text-white">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-8 w-8 shrink-0 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold line-clamp-1">{{ v.title }}</div>
|
||||
<div class="opacity-80 line-clamp-1">@{{ v.uploaderName }}</div>
|
||||
</div>
|
||||
<a *ngIf="watchLink()" [routerLink]="watchLink()" [queryParams]="watchQueryParams()"
|
||||
class="chip pointer-events-auto shrink-0 px-3 py-2 rounded bg-white/20 hover:bg-white/30 text-xs">Ouvrir</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Rail d'actions façon YouTube Shorts (à droite du cadre 9:16) -->
|
||||
<div class="flex shrink-0 flex-col items-center gap-2 pb-1">
|
||||
<a *ngIf="providerUrl()" [href]="providerUrl()" target="_blank" rel="noopener"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700"
|
||||
title="Ouvrir chez le fournisseur" aria-label="Ouvrir chez le fournisseur">
|
||||
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
<button type="button" (click)="skipUnplayable()"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-slate-800/80 text-white hover:bg-slate-700"
|
||||
title="Vidéo illisible ? Passer à la suivante" aria-label="Passer la vidéo illisible">
|
||||
<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="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (busyMore()) {
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-2 rounded-full bg-black/70 text-white text-xs" role="status">Chargement…</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation. Position indicator is always visible (it is the only progress
|
||||
feedback); the prev/next arrows are inset inside the page instead of
|
||||
parked outside the frame at `-right-14`, which used to overflow. -->
|
||||
<div class="flex w-full max-w-lg items-center justify-between gap-3">
|
||||
<button type="button" (click)="prev()" [disabled]="!canPrev()"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-3 text-white disabled:opacity-40"
|
||||
<!-- 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"
|
||||
aria-label="Short précédent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 14l5-5 5 5H7z"/></svg>
|
||||
<span class="ml-1">Préc.</span>
|
||||
<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;">
|
||||
<!-- 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">
|
||||
@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="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>
|
||||
</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">
|
||||
@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">
|
||||
<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">
|
||||
<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>
|
||||
}
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
@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'">
|
||||
{{ isSubscribed() ? 'Abonné' : "S'abonner" }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Barre de progression fine (YouTube, temps réel) -->
|
||||
@if (ytProgress() != null) {
|
||||
<div class="mt-2 h-0.5 w-full overflow-hidden rounded bg-white/20" aria-hidden="true">
|
||||
<div class="h-full bg-red-600 transition-[width]" [style.width.%]="(ytProgress() || 0) * 100"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (shareCopied()) {
|
||||
<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"
|
||||
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>
|
||||
}
|
||||
|
||||
<!-- Navigation fine (mobile + compteur) -->
|
||||
<div class="flex w-full items-center justify-between gap-3" style="max-width: 560px;">
|
||||
<button type="button" (click)="prev()" [disabled]="!canPrev()"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-2.5 text-white text-sm disabled:opacity-40"
|
||||
aria-label="Short précédent">
|
||||
<span>↑ Préc.</span>
|
||||
</button>
|
||||
@if (items().length > 0) {
|
||||
<span class="shrink-0 text-sm tabular-nums text-slate-400" role="status" aria-live="polite">
|
||||
@@ -97,12 +172,12 @@
|
||||
<span class="shrink-0"></span>
|
||||
}
|
||||
<button type="button" (click)="next()" [disabled]="!canNext()"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-3 text-white disabled:opacity-40"
|
||||
class="chip shrink-0 rounded-lg bg-slate-800/80 px-4 py-2.5 text-white text-sm disabled:opacity-40"
|
||||
aria-label="Short suivant">
|
||||
<span class="mr-1">Suiv.</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5H7z"/></svg>
|
||||
<span>Suiv. ↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (forYou() && (topics().length > 0 || topicsLoading())) {
|
||||
<p class="max-w-lg text-center text-xs text-slate-400" role="status" aria-live="polite">
|
||||
@if (topicsLoading()) {
|
||||
@@ -112,5 +187,5 @@
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<p class="max-w-lg text-center text-xs text-slate-500">Si une vidéo affiche une erreur (153, Forbidden, introuvable…), utilisez ⏭ Passer : elle est retirée du flux.</p>
|
||||
<p class="max-w-lg text-center text-xs text-slate-500">Molette, flèches ↑ ↓ ou balayage pour zapper. Son coupé par défaut (🔇) : l'activer reprend la vidéo au début. Vidéo illisible ? Menu ⋯ → Passer.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, HostListener } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, viewChild, HostListener, OnDestroy } from '@angular/core';
|
||||
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, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
@@ -17,6 +18,11 @@ import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/
|
||||
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
|
||||
const RUMBLE_SHORT_MAX_SECONDS = 90;
|
||||
|
||||
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
|
||||
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
|
||||
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru',
|
||||
};
|
||||
|
||||
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
|
||||
const TOPIC_STOPWORDS = new Set([
|
||||
'les', 'des', 'une', 'dans', 'avec', 'pour', 'sur', 'par', 'pas', 'plus', 'tout', 'tous', 'toute', 'toutes',
|
||||
@@ -53,10 +59,11 @@ const SHORTS_QUERIES: Record<string, string[]> = {
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './watch-short.component.html'
|
||||
})
|
||||
export class WatchShortComponent {
|
||||
export class WatchShortComponent implements OnDestroy {
|
||||
private api = inject(YoutubeApiService);
|
||||
private instances = inject(InstanceService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private http = inject(HttpClient);
|
||||
private history = inject(HistoryService);
|
||||
private likes = inject(LikesService);
|
||||
private downloads = inject(DownloadService);
|
||||
@@ -73,6 +80,52 @@ export class WatchShortComponent {
|
||||
forYou = signal(false);
|
||||
topics = signal<string[]>([]);
|
||||
topicsLoading = signal(false);
|
||||
|
||||
// ---- Expérience façon YouTube Shorts ----
|
||||
/** Cadre 9:16 (plein écran + focus). */
|
||||
frameRef = viewChild<ElementRef<HTMLElement>>('shortsFrame');
|
||||
/** Son coupé (autoplay) : rebascule l'URL d'embed (reprend au début). */
|
||||
muted = signal(true);
|
||||
/** Embed Rumble autoritaire résolu via /api/rumble/video/<id> (comme /watch). */
|
||||
resolvedRumbleEmbed = signal<Record<string, string>>({});
|
||||
private rumbleResolving = new Set<string>();
|
||||
private rumbleEmbedFailed = new Set<string>();
|
||||
/** J'aime (état + busy). */
|
||||
liked = signal(false);
|
||||
private likedCheckedFor = '';
|
||||
likeBusy = signal(false);
|
||||
/** Abonnement à la chaîne du short courant. */
|
||||
subBusy = signal(false);
|
||||
/** Partage : "copié" éphémère. */
|
||||
shareCopied = signal(false);
|
||||
private shareCopiedTimer: any = null;
|
||||
/** Menu ⋯. */
|
||||
menuOpen = signal(false);
|
||||
/** Progression YouTube (0..1), null si inconnue / non-YouTube. */
|
||||
ytProgress = signal<number | null>(null);
|
||||
/** Plein écran actif. */
|
||||
isFullscreen = signal(false);
|
||||
|
||||
readonly isLoggedIn = computed(() => {
|
||||
try { return !!this.auth.currentUser(); } catch { return false; }
|
||||
});
|
||||
|
||||
/** Id court d'abonnement + id externe de la chaîne courante (null si inconnu). */
|
||||
readonly currentChannelKey = computed<{ short: 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'; externalId: string } | null>(() => {
|
||||
const v = this.current();
|
||||
if (!v) return null;
|
||||
const p = this.providerOf(v);
|
||||
const short = SUB_PROVIDER_OF[p];
|
||||
const externalId = String((v as any)?.channelExternalId || (v as any)?.channelId || (v as any)?.channelHandle || '').trim();
|
||||
if (!short || !externalId) return null;
|
||||
return { short, externalId };
|
||||
});
|
||||
|
||||
readonly isSubscribed = computed(() => {
|
||||
const key = this.currentChannelKey();
|
||||
if (!key) return false;
|
||||
try { return this.subs.isSubscribed(key.short, key.externalId); } catch { return false; }
|
||||
});
|
||||
/** IDs déjà vus (dedup inter-pages pour l'infini). */
|
||||
private seenIds = new Set<string>();
|
||||
/** Position dans la rotation SHORTS_QUERIES pour le provider courant. */
|
||||
@@ -142,7 +195,8 @@ export class WatchShortComponent {
|
||||
origin = window.location.origin || '';
|
||||
ref = window.location.href || '';
|
||||
} catch {}
|
||||
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1`;
|
||||
const mute = this.muted() ? 1 : 0;
|
||||
let u = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(v.videoId)}?autoplay=1&mute=${mute}&rel=0&modestbranding=1&playsinline=1&enablejsapi=1`;
|
||||
if (origin) u += `&origin=${encodeURIComponent(origin)}`;
|
||||
if (ref) u += `&widget_referrer=${encodeURIComponent(ref)}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
@@ -151,7 +205,7 @@ export class WatchShortComponent {
|
||||
// Nouveau player obligatoire depuis fév. 2026 (l'ancien /embed est
|
||||
// coupé et répond "Forbidden"). Seuls mute/startTime/loop passent
|
||||
// en query sans Player ID ; autoplay est tenté au mieux.
|
||||
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=1&autoplay=1`;
|
||||
const u = `https://geo.dailymotion.com/player.html?video=${encodeURIComponent(v.videoId)}&mute=${this.muted() ? 1 : 0}&autoplay=1`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
if (p === 'twitch' && v.videoId) {
|
||||
@@ -160,7 +214,7 @@ export class WatchShortComponent {
|
||||
// Les Shorts Twitch = clips uniquement (jamais de live/channel).
|
||||
const clipId = (v as any).kind === 'clip' ? v.videoId : null;
|
||||
if (clipId) {
|
||||
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true`;
|
||||
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=true&muted=${this.muted()}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
// Repli : player channel/VOD (ne devrait pas arriver, les lives
|
||||
@@ -183,14 +237,18 @@ export class WatchShortComponent {
|
||||
if (p === 'odysee') {
|
||||
const slug = this.odyseeSlugOf(v);
|
||||
if (slug) {
|
||||
const u = `https://odysee.com/$/embed/${slug}?autoplay=1`;
|
||||
const u = `https://odysee.com/$/embed/${slug}?autoplay=1&muted=${this.muted() ? 1 : 0}`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
}
|
||||
}
|
||||
if (p === 'rumble' && (v.videoId || v.url)) {
|
||||
// Même base que /watch : /embed/<vId>/?pub=4&autoplay=1.
|
||||
// autoplay=2/muted=1 n'existent pas côté Rumble ("Video not found").
|
||||
// L'URL autoritaire résolue via /api/rumble/video/<id> (scrape de
|
||||
// la page, comme /watch) prime : les IDs de liste sont parfois
|
||||
// approximatifs ("Video not found" alors que Watch fonctionne).
|
||||
const id = this.rumbleIdOf(v);
|
||||
const resolved = (id && this.resolvedRumbleEmbed()[id]) || null;
|
||||
if (resolved) return this.sanitizer.bypassSecurityTrustResourceUrl(resolved);
|
||||
if (id) {
|
||||
const u = `https://rumble.com/embed/${id}/?pub=4&autoplay=1`;
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
|
||||
@@ -348,8 +406,200 @@ export class WatchShortComponent {
|
||||
this.fetchNextPage(false);
|
||||
}
|
||||
});
|
||||
// Hydrate abonnements (état du bouton S'abonner) une fois.
|
||||
try {
|
||||
if (this.subs.items().length === 0) this.subs.list().subscribe();
|
||||
this.subs.listGroups().subscribe();
|
||||
} catch {}
|
||||
// Résolution Rumble autoritaire (courant + suivant) + reset progression.
|
||||
effect(() => {
|
||||
const v = this.current();
|
||||
const i = this.index();
|
||||
const list = this.items();
|
||||
this.ytProgress.set(null);
|
||||
this.menuOpen.set(false);
|
||||
if (!v) return;
|
||||
if (this.providerOf(v) === 'rumble') this.ensureRumbleEmbed(v);
|
||||
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
|
||||
if (nx && this.providerOf(nx) === 'rumble') this.ensureRumbleEmbed(nx);
|
||||
// État J'aime du short courant (connecté uniquement).
|
||||
try {
|
||||
if (this.isLoggedIn() && v.videoId) {
|
||||
const key = `${String((v as any).provider || '')}:${v.videoId}`;
|
||||
if (key !== this.likedCheckedFor) {
|
||||
this.likedCheckedFor = key;
|
||||
this.liked.set(false);
|
||||
this.likes.isLiked(this.providerOf(v), v.videoId).subscribe({
|
||||
next: (r) => { if (this.likedCheckedFor === key) this.liked.set(!!(r as any)?.liked); },
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.liked.set(false);
|
||||
this.likedCheckedFor = '';
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
try { window.addEventListener('message', this.handleShortMessage); } catch {}
|
||||
try { document.addEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try { window.removeEventListener('message', this.handleShortMessage); } catch {}
|
||||
try { document.removeEventListener('fullscreenchange', this.handleFullscreenChange); } catch {}
|
||||
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
|
||||
}
|
||||
|
||||
/** Résout l'embed Rumble autoritaire via le backend (comme /watch). */
|
||||
private ensureRumbleEmbed(v: Video): void {
|
||||
const id = this.rumbleIdOf(v);
|
||||
if (!id || this.resolvedRumbleEmbed()[id] || this.rumbleResolving.has(id) || this.rumbleEmbedFailed.has(id)) return;
|
||||
this.rumbleResolving.add(id);
|
||||
this.http.get<any>(`/api/rumble/video/${encodeURIComponent(id)}`).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
this.rumbleResolving.delete(id);
|
||||
const eurl = String(data?.embedUrl || '');
|
||||
const m = /\/embed\/(v[0-9A-Za-z]+)/.exec(eurl);
|
||||
if (eurl && m?.[1]) {
|
||||
const clean = `https://rumble.com/embed/${m[1]}/?pub=4&autoplay=1`;
|
||||
this.resolvedRumbleEmbed.update(r => ({ ...r, [id]: clean }));
|
||||
} else {
|
||||
this.rumbleEmbedFailed.add(id);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.rumbleResolving.delete(id);
|
||||
this.rumbleEmbedFailed.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Actions façon YouTube Shorts ----
|
||||
toggleMute(): void {
|
||||
this.muted.update(m => !m);
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
const el = this.frameRef()?.nativeElement;
|
||||
if (el?.requestFullscreen) el.requestFullscreen();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private handleFullscreenChange = (): void => {
|
||||
try { this.isFullscreen.set(!!document.fullscreenElement); } catch {}
|
||||
};
|
||||
|
||||
toggleMenu(): void {
|
||||
this.menuOpen.update(o => !o);
|
||||
}
|
||||
closeMenu(): void {
|
||||
this.menuOpen.set(false);
|
||||
}
|
||||
|
||||
copyLink(): void {
|
||||
const url = this.providerUrl() || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
if (!url) return;
|
||||
const done = () => {
|
||||
this.shareCopied.set(true);
|
||||
this.menuOpen.set(false);
|
||||
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
|
||||
this.shareCopiedTimer = setTimeout(() => this.shareCopied.set(false), 2000);
|
||||
};
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url).then(done).catch(() => {});
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
share(): void {
|
||||
const v = this.current();
|
||||
const url = this.providerUrl() || '';
|
||||
this.menuOpen.set(false);
|
||||
try {
|
||||
const nav: any = navigator as any;
|
||||
if (nav?.share && url) {
|
||||
nav.share({ title: v?.title || 'Short', url }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
this.copyLink();
|
||||
}
|
||||
|
||||
toggleLike(): void {
|
||||
const v = this.current();
|
||||
if (!v?.videoId || !this.isLoggedIn() || this.likeBusy()) return;
|
||||
const p = this.providerOf(v);
|
||||
this.likeBusy.set(true);
|
||||
const done = (liked: boolean) => {
|
||||
this.liked.set(liked);
|
||||
this.likeBusy.set(false);
|
||||
};
|
||||
if (this.liked()) {
|
||||
this.likes.unlike(p, v.videoId).subscribe({
|
||||
next: () => done(false),
|
||||
error: () => this.likeBusy.set(false),
|
||||
});
|
||||
} else {
|
||||
this.likes.like(p, v.videoId, v.title, v.thumbnail).subscribe({
|
||||
next: () => done(true),
|
||||
error: () => this.likeBusy.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSubscribe(): void {
|
||||
const key = this.currentChannelKey();
|
||||
if (!key || !this.isLoggedIn() || this.subBusy()) return;
|
||||
this.subBusy.set(true);
|
||||
if (this.isSubscribed()) {
|
||||
try {
|
||||
const item = (this.subs.items() || []).find(
|
||||
s => (s as any)?.channel?.provider === key.short && (s as any)?.channel?.externalId === key.externalId
|
||||
);
|
||||
const id = (item as any)?.subscriptionId;
|
||||
if (id == null) { this.subBusy.set(false); return; }
|
||||
this.subs.unsubscribe(Number(id)).subscribe({
|
||||
next: () => this.subBusy.set(false),
|
||||
error: () => this.subBusy.set(false),
|
||||
});
|
||||
} catch { this.subBusy.set(false); }
|
||||
return;
|
||||
}
|
||||
this.subs.subscribe(key.short, key.externalId).subscribe({
|
||||
next: () => this.subBusy.set(false),
|
||||
error: () => this.subBusy.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Progression YouTube via postMessage (enablejsapi=1) : barre rouge façon Shorts. */
|
||||
private handleShortMessage = (ev: MessageEvent): void => {
|
||||
try {
|
||||
const origin = String(ev?.origin || '');
|
||||
if (!origin.includes('youtube')) return;
|
||||
const v = this.current();
|
||||
if (!v || this.providerOf(v) !== 'youtube') return;
|
||||
const data = typeof ev?.data === 'string' ? JSON.parse(ev.data) : ev?.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') {
|
||||
const ct = Number(data.info.currentTime);
|
||||
let dur = Number(data.info.duration);
|
||||
if (!Number.isFinite(dur) || dur <= 0) dur = Number((v as any)?.duration || 0);
|
||||
if (Number.isFinite(ct) && ct >= 0 && dur > 0) {
|
||||
this.ytProgress.set(Math.max(0, Math.min(1, ct / dur)));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Répartiteur du flux Shorts par provider :
|
||||
* - PeerTube : vidéos locales uniquement (les distantes échouent à
|
||||
|
||||
@@ -353,11 +353,23 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null;
|
||||
if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null;
|
||||
const items = Object.values(r).flatMap(x => x?.items || []);
|
||||
// Filet de sécurité : si VODs+clips vides et aucun live, compléter par top streams.
|
||||
// Filets de sécurité : si VODs+clips vides et aucun live, compléter
|
||||
// par top streams (repli serveur inclus), puis par la recherche
|
||||
// serveur unifiée (/api/search?providers=tw).
|
||||
const hasContent = items.length > 0;
|
||||
if (!hasContent && firstPage) {
|
||||
this.api.getTwitchTopStreamsPage(null).subscribe({
|
||||
next: (top) => finish([...items, ...(top.items || [])], top.nextCursor || null),
|
||||
next: (top) => {
|
||||
const all = [...items, ...(top.items || [])];
|
||||
if (all.length) {
|
||||
finish(all, top.nextCursor || null);
|
||||
return;
|
||||
}
|
||||
this.api.searchTwitchViaServer(query, 24).subscribe({
|
||||
next: (srv) => finish([...all, ...(srv.items || [])], srv.nextCursor || null),
|
||||
error: () => finish(all, null),
|
||||
});
|
||||
},
|
||||
error: () => finish(items, null),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1544,7 +1544,7 @@ export class YoutubeApiService {
|
||||
public getTwitchTopStreamsPage(cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(timeout(30000),
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
if (!token) return this.getTwitchServerTopStreams(limit);
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`;
|
||||
@@ -1555,12 +1555,84 @@ export class YoutubeApiService {
|
||||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||||
nextCursor: res.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch top streams error', e); return of({ items: [], nextCursor: null }); })
|
||||
// Helix direct vide/en erreur (proxy, 429, CORS…) : repli serveur.
|
||||
switchMap(res => (res?.items?.length ? of(res) : this.getTwitchServerTopStreams(limit))),
|
||||
catchError(() => this.getTwitchServerTopStreams(limit))
|
||||
);
|
||||
})
|
||||
}),
|
||||
catchError(() => this.getTwitchServerTopStreams(limit))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top streams via le serveur (`GET /api/twitch/top-streams`, token +
|
||||
* retries côté back). Repli fiable pour Home/Explore quand les appels
|
||||
* Helix directs du navigateur échouent.
|
||||
*/
|
||||
public getTwitchServerTopStreams(limit = 24): Observable<PagedResult<Video>> {
|
||||
const n = Math.min(50, Math.max(1, Number(limit) || 24));
|
||||
return this.http.get<any>(`/api/twitch/top-streams?first=${n}`).pipe(
|
||||
timeout(20000),
|
||||
map(res => ({
|
||||
items: ((res?.items || []) as any[]).map(i => this.mapTwitchServerItemToVideo(i)),
|
||||
nextCursor: null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch server top streams error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche Twitch via le serveur unifié (`/api/search?providers=tw`,
|
||||
* token + retries + filets côté back). Dernier repli des pages Explore.
|
||||
*/
|
||||
public searchTwitchViaServer(query: string, limit = 24): Observable<PagedResult<Video>> {
|
||||
const q = String(query || '').trim();
|
||||
if (!q) return this.getTwitchServerTopStreams(limit);
|
||||
const n = Math.min(50, Math.max(1, Number(limit) || 24));
|
||||
const url = `/api/search?q=${encodeURIComponent(q)}&providers=tw&pageSize=${n}`;
|
||||
return this.http.get<any>(url).pipe(
|
||||
timeout(25000),
|
||||
map(res => {
|
||||
const list = (res?.groups?.tw || res?.items || []) as any[];
|
||||
return {
|
||||
items: list.map(i => this.mapTwitchServerItemToVideo(i)).filter(v => !!v.videoId),
|
||||
nextCursor: null,
|
||||
} as PagedResult<Video>;
|
||||
}),
|
||||
catchError((e) => { console.error('Twitch server search error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
}
|
||||
|
||||
/** Mappe un item Twitch "forme serveur" (twitch.mjs) vers Video. */
|
||||
private mapTwitchServerItemToVideo = (i: any): Video => {
|
||||
const kind = String(i?.kind || '').toLowerCase();
|
||||
const type = String(i?.type || 'video');
|
||||
const login: string | undefined = i?.channelHandle || i?.channelExternalId || undefined;
|
||||
const thumb: string = String(i?.thumbnail || i?.uploaderAvatar || '') ||
|
||||
'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||||
return {
|
||||
url: i?.url || (login ? `https://www.twitch.tv/${login}` : `/watch/${i?.id || ''}`),
|
||||
videoId: String(i?.id || ''),
|
||||
type,
|
||||
provider: 'twitch',
|
||||
kind: kind === 'clip' ? 'clip' : kind === 'vod' ? 'vod' : undefined,
|
||||
title: i?.title || '',
|
||||
thumbnail: thumb,
|
||||
uploaderName: i?.uploaderName || '',
|
||||
uploaderUrl: login ? `https://www.twitch.tv/${login}` : '',
|
||||
uploaderAvatar: i?.uploaderAvatar || '',
|
||||
channelId: i?.channelId || undefined,
|
||||
channelHandle: login,
|
||||
channelExternalId: login || undefined,
|
||||
channelAvatarUrl: i?.uploaderAvatar || undefined,
|
||||
uploadedDate: i?.publishedAt || '',
|
||||
duration: Number(i?.duration || 0),
|
||||
views: Number(i?.views || 0),
|
||||
uploaded: i?.publishedAt ? Date.parse(i.publishedAt) : 0,
|
||||
isLive: i?.isLive === true,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche thématique Twitch robuste : VODs puis clips, avec fallback vers
|
||||
* les top streams live si les deux sont quasi vides (évite les blocs vides
|
||||
|
||||
Reference in New Issue
Block a user