CI / build-and-test (push) Successful in 14m55s
Retour utilisateur : les flèches Préc./Suiv. n'étaient toujours pas visibles et la position attendue est précisée — une à GAUCHE et une à DROITE de la vidéo, centrées verticalement. - Flèches remises aux bords gauche/droite du cadre (top-1/2, -translate-y-1/2) avec contraste renforcé : ring-2 white/50, fond black/55 — lisibles sur toute image. - Sorties de @if (current()) comme le menu : TOUJOURS montées, donc présentes même sur l'état de flux mort (désactivées) — on ne cherche plus où naviguer. - Chaque changement de vidéo réveille les contrôles 2,5 s (poke sous matchMedia hover : sur tactile le chrome reste visible en permanence, un poke y aurait armé le minuteur et masqué le chrome — régression détectée par la recette mobile puis corrigée). - Recette Playwright desktop+mobile : 17/17 (bords à 6 px, centrage vertical ±1 px, fondu repos 0.18, plein au mouvement, mobile plein, pas de chevauchement 2D avec la flèche retour, flèches + menu présents sur feed mort). Build + test:section + test:shorts-catalog verts avant commit.
240 lines
19 KiB
HTML
240 lines
19 KiB
HTML
<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"
|
||
[class.shorts-chrome-hidden]="!chromeVisible()"
|
||
(click)="closeMenu()" (mousemove)="pokeChrome()">
|
||
<!-- 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>
|
||
|
||
<!-- Retour accueil (mobile plein écran : la vidéo occupe tout, pas de header visible) -->
|
||
<a routerLink="/"
|
||
class="absolute top-2 left-2 z-20 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white ring-1 ring-white/15 backdrop-blur-md hover:bg-black/70 sm:hidden"
|
||
title="Retour à l'accueil NewTube" aria-label="Retour à l'accueil NewTube" (click)="$event.stopPropagation()">
|
||
<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="M19 12H5m7-7l-7 7 7 7"/></svg>
|
||
</a>
|
||
|
||
<!-- 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)">
|
||
<!-- 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" [class.embed-wide]="wideEmbed()" frameborder="0" playsinline
|
||
(mouseenter)="pokeChrome()"
|
||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||
allowfullscreen referrerpolicy="origin"></iframe>
|
||
} @else if (embedWaiting()) {
|
||
<!-- Embed Twitch en attente d'un geste : le premier chargement de
|
||
l'iframe doit tomber dans un contexte d'activation utilisateur
|
||
(méthode « listener b4 loading »). N'importe quel geste sur la
|
||
page charge la vidéo. -->
|
||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black text-slate-300" role="status">
|
||
<svg class="h-12 w-12 text-white/70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||
<span class="text-sm">Touchez pour charger la vidéo</span>
|
||
</div>
|
||
} @else if (loading()) {
|
||
<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-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>
|
||
}
|
||
|
||
<!-- Menu FAB : toutes les options (fournisseur, langue, actions). -->
|
||
<app-player-menu [(open)]="menuOpen">
|
||
<div class="pm-section-label">Fournisseur</div>
|
||
<button type="button" (click)="toggleForYou(); closeMenu()" class="pm-item" [class.text-violet-300]="forYou()" [attr.aria-pressed]="forYou()">
|
||
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white" aria-hidden="true">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c.7 4.8 3.2 7.3 8 8-4.8.7-7.3 3.2-8 8-.7-4.8-3.2-7.3-8-8 4.8-.7 7.3-3.2 8-8z"/><path d="M19 3c.3 2 1.3 3 3.3 3.3-2 .3-3 1.3-3.3 3.2-.3-1.9-1.3-2.9-3.2-3.2 1.9-.3 2.9-1.3 3.2-3.3z"/></svg>
|
||
</span>
|
||
<span class="pm-item-label">Pour toi</span>
|
||
@if (forYou()) { <svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-4 w-4 text-white/80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg> }
|
||
</button>
|
||
@for (p of providerButtons(); track p.id) {
|
||
<button type="button" (click)="selectProvider(p.id); closeMenu()" class="pm-item" [attr.aria-pressed]="!forYou() && provider() === p.id">
|
||
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white" [class]="p.disc" aria-hidden="true">
|
||
@switch (p.glyph.kind) {
|
||
@case ('play') {
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||
}
|
||
@case ('chat') {
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="3" width="16" height="13" rx="2"/><path d="M9 16v4l4-4"/><line x1="10" y1="7" x2="10" y2="11"/><line x1="14" y1="7" x2="14" y2="11"/></svg>
|
||
}
|
||
@default {
|
||
<span class="text-[11px] font-extrabold leading-none">{{ glyphLetter(p) }}</span>
|
||
}
|
||
}
|
||
</span>
|
||
<span class="pm-item-label">{{ p.label }}</span>
|
||
@if (!forYou() && provider() === p.id) { <svg xmlns="http://www.w3.org/2000/svg" class="ml-auto h-4 w-4 text-white/80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg> }
|
||
</button>
|
||
}
|
||
|
||
<div class="pm-sep"></div>
|
||
|
||
<button type="button" (click)="cycleLangFilter()" class="pm-item" [title]="langFilterHint()" [attr.aria-label]="langFilterHint()">
|
||
<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="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
<span class="pm-item-label">Langue</span>
|
||
<span class="pm-item-hint">{{ langFilterShort() }}</span>
|
||
</button>
|
||
|
||
<div class="pm-sep"></div>
|
||
|
||
<button type="button" (click)="toggleLike()" class="pm-item" [disabled]="!isLoggedIn() || likeBusy()"
|
||
[title]="isLoggedIn() ? (liked() ? 'Je n’aime plus' : 'J’aime') : 'Connectez-vous pour aimer'">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
||
<span class="pm-item-label">{{ liked() ? 'Je n’aime plus' : 'J’aime' }}</span>
|
||
</button>
|
||
<button type="button" (click)="toggleMute()" class="pm-item" [title]="muted() ? 'Activer le son' : '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>
|
||
}
|
||
<span class="pm-item-label">{{ muted() ? 'Activer le son' : 'Couper le son' }}</span>
|
||
</button>
|
||
<button type="button" (click)="toggleFullscreen(); closeMenu()" class="pm-item" [title]="isFullscreen() ? 'Quitter le plein écran' : '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>
|
||
}
|
||
<span class="pm-item-label">{{ isFullscreen() ? 'Quitter le plein écran' : 'Plein écran' }}</span>
|
||
</button>
|
||
@if (watchLink()) {
|
||
<a [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="closeMenu()" class="pm-item" title="Voir les commentaires dans Watch">
|
||
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<span class="pm-item-label">Commentaires</span>
|
||
</a>
|
||
}
|
||
<button type="button" (click)="share()" class="pm-item" title="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>
|
||
<span class="pm-item-label">{{ shareCopied() ? 'Lien copié ✓' : 'Partager' }}</span>
|
||
</button>
|
||
@if (providerUrl()) {
|
||
<a [href]="providerUrl()" target="_blank" rel="noopener" (click)="closeMenu()" class="pm-item">
|
||
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg>
|
||
<span class="pm-item-label">Ouvrir chez le fournisseur</span>
|
||
</a>
|
||
}
|
||
@if (watchLink()) {
|
||
<a [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="closeMenu()" class="pm-item">
|
||
<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"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 9l5 3-5 3V9z"/></svg>
|
||
<span class="pm-item-label">Ouvrir dans Watch</span>
|
||
</a>
|
||
}
|
||
@if (currentSource()) {
|
||
<div class="px-3 py-1.5 text-[11px] text-slate-400">Via le catalogue • {{ currentSource() }}</div>
|
||
}
|
||
<button type="button" (click)="skipUnplayable(); closeMenu()" class="pm-item">
|
||
<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="M4 5v14l8-7zM12 5v14l8-7z"/></svg>
|
||
<span class="pm-item-label">Passer (vidéo illisible)</span>
|
||
</button>
|
||
</app-player-menu>
|
||
<!-- Navigation précédent / suivant : TOUJOURS montés (comme le menu),
|
||
aux bords GAUCHE/DROITE de la vidéo, centrés verticalement —
|
||
présents même sans vidéo (désactivés). Contraste renforcé pour
|
||
rester lisibles sur toute image ; fondu au repos (.shorts-ctrl,
|
||
index.css), stopPropagation dans les handlers. -->
|
||
<button type="button" (click)="prev(); $event.stopPropagation()" [disabled]="!canPrev()"
|
||
class="shorts-ctrl 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/55 text-white ring-2 ring-white/50 backdrop-blur-md transition hover:bg-black/75 disabled:opacity-40"
|
||
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="shorts-ctrl 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/55 text-white ring-2 ring-white/50 backdrop-blur-md transition hover:bg-black/75 disabled:opacity-40"
|
||
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 (current(); as v) {
|
||
|
||
<!-- Capteur tactile (mobile uniquement) : l'iframe mange les
|
||
touchers, donc le swipe up/down passerait à la trappe. Cette
|
||
couche transparente capte swipe (navigue) et tap (play/pause
|
||
YouTube, ou accès direct au lecteur 6 s sinon). -->
|
||
<div class="shorts-gesture absolute inset-0 z-[1] sm:hidden"
|
||
[class.pointer-events-none]="passthrough()"
|
||
aria-hidden="true"></div>
|
||
|
||
@if (passthroughHint()) {
|
||
<div class="absolute left-1/2 top-14 z-20 -translate-x-1/2 whitespace-nowrap px-3 py-1.5 rounded-full bg-black/75 text-white text-xs" role="status">Commandes du lecteur accessibles</div>
|
||
}
|
||
|
||
<!-- Bas : avatar + titre + méta + fraîcheur + S'abonner (safe-area incluse) -->
|
||
<div class="shorts-meta shorts-chrome pointer-events-none absolute left-0 right-0 bottom-0 z-10 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 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.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>
|
||
}
|
||
</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 z-30 px-3 py-1.5 rounded-full bg-black/70 text-white text-xs" role="status">Lien copié ✓</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
@if (busyMore()) {
|
||
<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). -->
|
||
</div>
|