Files
NewTube/src/components/shorts/watch-short.component.html
T
bruno 6b32ede302
CI / build-and-test (push) Successful in 14m55s
fix(shorts): flèches aux bords gauche/droite, toujours montées, visibles
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.
2026-10-02 12:34:13 -04:00

240 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>