feat(shorts): flèches Préc./Suivant SUR la vidéo + embed Rumble en 16:9
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
- Flèches : elles vivaient EN DEHORS du cadre (et se fadaient avec le chrome auto-hide). Elles sont désormais posées dans le cadre, à mi-hauteur sur les bords, toujours visibles (classe `shorts-chrome` retirée), sur mobile comme sur bureau, et le stopPropagation protège le geste tactile. - Rumble : la plateforme est16:9 mais n'expose ni largeur ni hauteur → l'embed tombait dans un cadre 9:16 (étiré / réduit). `isWideEmbed()` (pur + testé) décide de la boîte : dimensions connues => orientation réelle, sinon repli « rumble = paysage » ; la CSS `.embed-wide` rend une iframe 16:9 centrée (434x244 dans un cadre 434x771, mesuré). Les vidéos verticales (dimensions connues) gardent le cadre pleine hauteur. - État vide : un fournisseur sans résultat laissait un cadre NOIR muet (ni squelette, ni message) — il affiche désormais « Aucun short à afficher pour ce fournisseur » + Réessayer. Tests : test:shorts-catalog (3 cas isWideEmbed), test:kind, test:section, test:search verts ; build ng OK ; sondage Playwright local : flèches dans le cadre + navigation suivant/précédent réelle, boîte embed-wide 16:9 centrée, short vertical inchangé (iframe pleine hauteur), état vide affiché.
This commit is contained in:
@@ -517,6 +517,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Embed PAYSAGE dans le cadre vertical : la boîte de l'iframe suit le16:9
|
||||
(sinon le lecteur étire ou réduit la vidéo dans un cadre 9:16). */
|
||||
.shorts-frame iframe.embed-wide {
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* --- Shorts immersive stage (façon YouTube) -------------------------------- */
|
||||
/* Plein écran vertical : le cadre 9:16 flotte sur un fond d'ambiance tiré de
|
||||
la vignette courante. `100dvh` (barres navigateur mobiles) avec repli. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
catalogShortToVideo,
|
||||
isWideEmbed,
|
||||
formatShortMeta,
|
||||
formatCapturedAgo,
|
||||
formatCompactFr,
|
||||
@@ -32,6 +33,13 @@ function assertEqual(actual: unknown, expected: unknown, message: string): void
|
||||
|
||||
const NOW = 1700000000000;
|
||||
|
||||
console.log('--- isWideEmbed ---');
|
||||
assertEqual(isWideEmbed({ width: 1920, height: 1080 }, 'rumble'), true, '16:9 connu → paysage');
|
||||
assertEqual(isWideEmbed({ width: 720, height: 1280 }, 'youtube'), false, 'vertical connu → pas paysage');
|
||||
assertEqual(isWideEmbed({}, 'rumble'), true, 'Rumble sans dimensions → paysage (repli plateforme)');
|
||||
assertEqual(isWideEmbed({}, 'youtube'), false, 'dimensions inconnues hors Rumble → on ne tranche pas');
|
||||
assertEqual(isWideEmbed(null, 'rumble'), true, 'item nul → repli plateforme');
|
||||
|
||||
console.log('--- catalogShortToVideo ---');
|
||||
assertEqual(catalogShortToVideo(null), null, 'null → null');
|
||||
assertEqual(catalogShortToVideo({} as any), null, 'sans id → null (pas de fantôme)');
|
||||
|
||||
@@ -91,6 +91,24 @@ export function catalogShortToVideo(item: VideoItem | null | undefined): Video |
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La vidéo est-elle PAYSAGE dans le cadre vertical (9:16) de /shorts ?
|
||||
*
|
||||
* Rumble n'expose ni largeur ni hauteur et sa plateforme est16:9 : sans cela
|
||||
* son embed est écrasé dans un cadre portrait (étiré ou réduit à un tiers de
|
||||
* l'écran). Dimensions connues => orientation réelle ; inconnues => repli sur
|
||||
* la nature de la plateforme (rumble = paysage, les autres = on ne tranche pas).
|
||||
*/
|
||||
export function isWideEmbed(
|
||||
v: { width?: number | null; height?: number | null } | null | undefined,
|
||||
provider: string,
|
||||
): boolean {
|
||||
const w = Number(v?.width) || 0;
|
||||
const h = Number(v?.height) || 0;
|
||||
if (w > 0 && h > 0) return w > h;
|
||||
return provider === 'rumble';
|
||||
}
|
||||
|
||||
/** Nombre compact fr (« 12 k », « 3,4 M ») — `Intl` plutôt qu'un format maison. */
|
||||
export function formatCompactFr(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '';
|
||||
|
||||
@@ -18,19 +18,12 @@
|
||||
<!-- 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="shorts-chrome chip hidden sm:flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-black/40 text-white ring-1 ring-white/15 hover:bg-black/60 disabled:opacity-30"
|
||||
aria-label="Short précédent">
|
||||
<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="M7 14l5-5 5 5H7z"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- 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 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
|
||||
<iframe [src]="url" [title]="current()?.title || 'Short video'" class="absolute inset-0 w-full h-full" [class.embed-wide]="wideEmbed()" frameborder="0" playsinline
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen referrerpolicy="origin"></iframe>
|
||||
} @else if (embedWaiting()) {
|
||||
@@ -52,6 +45,13 @@
|
||||
<span>{{ error() }}</span>
|
||||
<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>
|
||||
} @else {
|
||||
<!-- Fournisseur vide (catalogue maigre + repli historique sans rien)
|
||||
: surtout ne pas laisser un cadre noir muet. -->
|
||||
<div class="absolute inset-0 flex flex-col gap-3 items-center justify-center bg-black text-slate-400 text-sm p-6 text-center">
|
||||
<span>Aucun short à afficher pour ce fournisseur.</span>
|
||||
<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) {
|
||||
@@ -204,6 +204,19 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation précédent / suivant : SUR la vidéo (hors auto-hide du
|
||||
chrome, hors geste tactile — stopPropagation dans les handlers). -->
|
||||
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
||||
class="absolute left-1.5 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/70 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="M15.5 4.5 8 12l7.5 7.5z"/></svg>
|
||||
</button>
|
||||
<button type="button" (click)="next(); $event.stopPropagation()" [disabled]="!canNext()"
|
||||
class="absolute right-1.5 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/70 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="M8.5 4.5 16 12l-7.5 7.5z"/></svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (shareCopied()) {
|
||||
@@ -212,12 +225,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flèche suivant (bureau, façon YouTube) -->
|
||||
<button type="button" (click)="next(); $event.stopPropagation()" [disabled]="!canNext()"
|
||||
class="shorts-chrome chip hidden sm:flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-black/40 text-white ring-1 ring-white/15 hover:bg-black/60 disabled:opacity-30"
|
||||
aria-label="Short suivant">
|
||||
<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="M7 10l5 5 5-5H7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (busyMore()) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SearchService } from '../../app/search/search.service';
|
||||
import { ChannelProviderFactory } from '../../app/core/providers/channel/channel-provider.factory';
|
||||
import {
|
||||
catalogShortToVideo,
|
||||
isWideEmbed,
|
||||
formatShortMeta,
|
||||
formatCapturedAgo,
|
||||
normalizeShortsLangMode,
|
||||
@@ -505,6 +506,12 @@ export class WatchShortComponent implements OnDestroy {
|
||||
return null;
|
||||
});
|
||||
|
||||
/** Encadré 16:9 de l'embed (voir `isWideEmbed`). */
|
||||
readonly wideEmbed = computed(() => {
|
||||
const v = this.current();
|
||||
return v ? isWideEmbed(v, this.providerOf(v)) : false;
|
||||
});
|
||||
|
||||
/** Host PeerTube déduit de l'URL vidéo (fédération), sinon null. */
|
||||
private peerTubeHostOf(v: Video): string | null {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user