feat(shorts): le panneau ⓘ demande la description et les vues à la source
CI / build-and-test (push) Canceled after 0s

Le flux Shorts ne porte pas de description (et pas toujours de vues) : le
panneau affichait « le fournisseur ne transmet pas de description » alors que
`/api/details` la connaît. Il l'interroge maintenant à l'OUVERTURE, une fois
par vidéo (chargement squelette, échec silencieux + retentative au clic
suivant), et le serveur sert le tout depuis son cache 6 h.

- `detailsEnriched()` + `detailsLoading()` : description et vues réelles,
  `panelDescription()`/`panelMeta()` préfèrent le flux puis complètent.
- Paramètres par fournisseur réutilisés (`instance` PeerTube via
  `peerTubeHostOf`, `slug` Odysee via `odyseeSlugOf`) : mêmes règles que le
  reste du composant, pas de second jeu de conventions.
- Rafraîchi si le panneau reste ouvert pendant un changement de short.
This commit is contained in:
2026-10-02 14:55:39 -04:00
parent 7aea8e2c7b
commit ba7085c321
2 changed files with 76 additions and 4 deletions
@@ -312,7 +312,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="mt-1 text-[12px] text-white/60">{{ providerLabel() }}<span> • </span>{{ currentMeta() }}</div>
<div class="mt-1 text-[12px] text-white/60">{{ providerLabel() }}<span> • </span>{{ panelMeta() }}</div>
<!-- Barre d'engagement -->
<div class="mt-3 flex items-center gap-2 border-y border-white/10 py-2">
@@ -351,8 +351,13 @@
</div>
<!-- Détails et description -->
@if (currentDescription()) {
<p class="mt-3 whitespace-pre-line break-words rounded-xl bg-white/5 p-3 text-[12px] leading-relaxed text-white/75">{{ currentDescription() }}</p>
@if (panelDescription()) {
<p class="mt-3 whitespace-pre-line break-words rounded-xl bg-white/5 p-3 text-[12px] leading-relaxed text-white/75">{{ panelDescription() }}</p>
} @else if (detailsLoading()) {
<div class="mt-3 space-y-1.5 rounded-xl bg-white/5 p-3" role="status" aria-label="Chargement des informations">
<div class="h-2.5 w-4/5 animate-pulse rounded bg-white/15"></div>
<div class="h-2.5 w-3/5 animate-pulse rounded bg-white/15"></div>
</div>
} @else {
<p class="mt-3 text-[12px] leading-relaxed text-white/40">Le fournisseur ne transmet pas de description pour ce short — « Ouvrir chez le fournisseur » ci-dessous donne la version complète.</p>
}
+68 -1
View File
@@ -174,6 +174,15 @@ export class WatchShortComponent implements OnDestroy {
isFullscreen = signal(false);
/** Panneau « informations » (colonne au bureau, tiroir sur mobile). */
infoOpen = signal(false);
/**
* Détails enrichis du panneau ⓘ (description + vues) : chargés À L'OUVERTURE
* via `/api/details` (côté serveur : 6 h de cache). Le flux Shorts ne porte
* pas de description chez la plupart des fournisseurs — le panneau la
* demandait donc à la source, mais une fois, à la demande.
*/
readonly detailsEnriched = signal<{ description?: string; views?: number } | null>(null);
detailsLoading = signal(false);
private detailsFetchedFor = '';
/**
* Lecture auto (avance en fin de vidéo) : activée par défaut, pilotée par
* le bouton du rail et persistée. `scheduleAutoAdvance` ET l'événement de
@@ -189,6 +198,22 @@ export class WatchShortComponent implements OnDestroy {
const v = this.current() as (Video & { shortDescription?: string | null }) | null;
try { return String(v?.shortDescription || '').trim(); } catch { return ''; }
});
/** Description du panneau ⓘ : celle du flux, sinon celle enrichie à la demande. */
readonly panelDescription = computed(() => {
const fromFeed = this.currentDescription();
if (fromFeed) return fromFeed;
return String(this.detailsEnriched()?.description || '').trim();
});
/** Méta du panneau ⓘ (vues + date) : complétée par l'enrichissement. */
readonly panelMeta = computed(() => {
const v = this.current();
if (!v) return '';
const feedViews = (typeof v.views === 'number' && v.views > 0) ? v.views : undefined;
const views = feedViews ?? this.detailsEnriched()?.views;
try { return formatShortMeta(views, v.uploadedDate); } catch { return ''; }
});
/** Dernier index vu par l'effet de transition (glissade du cadre). */
private slideFrom = -1;
/** Fin de l'animation de sortie en cours : l'entrée attend son tour. */
@@ -725,6 +750,10 @@ export class WatchShortComponent implements OnDestroy {
this.ytPlaying.set(true);
this.menuOpen.set(false);
if (!v) return;
// Le panneau ⓘ avait les détails de la vidéo précédente : on les jette,
// et on les redemande tout de suite s'il est resté ouvert (bureau).
this.detailsEnriched.set(null);
if (this.infoOpen()) this.loadDetailsOnce();
const nx = (i >= 0 && i + 1 < list.length) ? list[i + 1] : null;
this.queueRumbleResolves([v, nx]);
// Chaque nouvelle vidéo réveille les contrôles (flèches + menu) pendant
@@ -1104,7 +1133,45 @@ export class WatchShortComponent implements OnDestroy {
/** Panneau d'informations (bureau : colonne à droite ; mobile : tiroir). */
toggleInfo(): void {
this.infoOpen.update(o => !o);
const open = !this.infoOpen();
this.infoOpen.set(open);
if (open) this.loadDetailsOnce();
}
/**
* Charge (une fois par vidéo) la description et les vues via `/api/details`.
* Échec silencieux : le panneau reste utilisable avec les données du flux,
* et le prochain clic sur ⓘ retente (clé non marquée comme chargée).
*/
private loadDetailsOnce(): void {
try {
const v = this.current();
if (!v?.videoId) return;
const p = this.providerOf(v);
const key = `${p}:${v.videoId}`;
if (key === this.detailsFetchedFor) return;
this.detailsFetchedFor = key;
this.detailsLoading.set(true);
const params: Record<string, string> = {};
if (p === 'peertube') {
const inst = this.peerTubeHostOf(v) || this.instances.activePeerTubeInstance();
if (inst) params['instance'] = inst;
}
if (p === 'odysee') {
const slug = this.odyseeSlugOf(v);
if (slug) params['slug'] = slug;
}
this.http.get<any>(`/api/details/${encodeURIComponent(p)}/${encodeURIComponent(v.videoId)}`, { params }).subscribe({
next: (d) => {
this.detailsLoading.set(false);
this.detailsEnriched.set({
description: String(d?.description || '').trim(),
views: typeof d?.views === 'number' && d.views > 0 ? d.views : undefined,
});
},
error: () => { this.detailsLoading.set(false); this.detailsFetchedFor = ''; },
});
} catch { this.detailsLoading.set(false); }
}
/** Interrupteur « lecture auto » du rail (persisté). */