feat(shorts): UI/UX — rail d'actions à droite (flèches ↑/↓), panneau d'infos, glissement magnétique
CI / build-and-test (push) Canceled after 8s

- Supprime les flèches Préc./Suiv. aux bords gauche/droite (troubles
  d'affichage récurrents) : la navigation passe au rail vertical ancré au
  bord DROIT de la vidéo, centré au bureau et ancré au-dessus de la méta
  sur mobile, avec des flèches ↑/↓ plus grosses (44 px).
- Même rail : informations, j'aime, s'abonner (pastille = avatar), commentaires,
  partager, lecture auto. Toujours monté (comme le FAB), désactivé sans vidéo.
- Bouton ⓘ : panneau d'informations (titre, vues, engagement, chaîne,
  description, commentaires → Watch). Colonne à droite au bureau (la vidéo
  rétrécit, jamais masquée : --shorts-panel-w retiré du calcul de taille),
  tiroir plein écran sur mobile. Échap ferme.
- Transition de short : le doigt fait glisser le cadre (chrome solidaire),
  résistance aux extrémités, seuil de bascule 25 % de la hauteur, ressort
  sous le seuil, éjection + emboîtement au-delà (Web Animations, respecte
  prefers-reduced-motion). Aucun état de lecture modifié.
- Lecture auto : interrupteur réel (rail + menu), persisté, respecté par le
  minuteur et par la fin de vidéo YouTube.
- Tap sur un contrôle du rail = plus de play/pause parasite (garde
  isChromeTarget dans onTouchStart/onTouchEnd).
- FAB menu conservé (haut droit, déplaçable) ; son touchmove ne déplace plus
  le cadre en même temps.
This commit is contained in:
2026-10-02 13:37:50 -04:00
parent 6b32ede302
commit 99e01c0679
4 changed files with 446 additions and 49 deletions
+113 -9
View File
@@ -497,7 +497,7 @@ body {
max-width: 100%;
height: min(
calc(var(--app-vh) - var(--header-offset) - var(--shorts-chrome)),
calc((100vw - 2rem) * 16 / 9)
calc((100vw - 2rem - var(--shorts-panel-w, 0px)) * 16 / 9)
);
min-height: 0;
}
@@ -515,6 +515,11 @@ body {
.shorts-frame {
--shorts-chrome: 5.5rem;
}
/* Le rail est ancré au-dessus de la méta (bas) : il ne doit jamais la
chevaucher — sur un écran court il défile en interne au lieu de rogner. */
.shorts-rail {
max-height: calc(100% - 11rem);
}
}
/* Embed PAYSAGE dans le cadre vertical : la boîte de l'iframe suit le16:9
@@ -572,19 +577,118 @@ body {
opacity: 0;
pointer-events: none;
}
/* Contrôles flottants (rangée de flèches + FAB menu) : presque fondu au repos
pour laisser la vidéo respirer (ton pro), pleins dès le moindre mouvement
de souris (chromeVisible) ou survol direct. Le panneau ouvert et le FAB
ouvert ne sont jamais atténués. Le tactile ne déclenche presque pas
mousemove : sur mobile tout reste donc plein (comportement voulu). */
.shorts-frame .shorts-ctrl,
/* --- Shorts : rail d'actions (bord DROIT de la vidéo) --------------------- */
/* Colonne d'icônes façon Shorts : informations, flèches ↑/↓, j'aime,
s'abonner, commentaires, partager, lecture auto. TOUJOURS montée (c'est la
navigation utilisable dans tous les états, avec le FAB). Ancrage bas sur
mobile (pouce, au-dessus de la méta) puis centré verticalement au bureau
(classes du template). */
.shorts-rail {
max-height: calc(100% - 1.5rem);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.shorts-rail::-webkit-scrollbar { display: none; }
.shorts-rail-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 2.5rem; /* 40px : cible tactile mobile */
height: 2.5rem;
border-radius: 9999px;
color: #fff;
background: rgba(0, 0, 0, .55);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition: opacity .25s ease, background .15s ease, transform .15s ease;
}
.shorts-rail-btn:hover:not(:disabled) { background: rgba(0, 0, 0, .75); transform: scale(1.06); }
.shorts-rail-btn:disabled { opacity: .4; }
.shorts-rail-btn svg { width: 1.15rem; height: 1.15rem; }
/* Flèches ↑/↓ : plus grosses (c'est LA cible de défilement). */
.shorts-rail-btn--nav { width: 2.75rem; height: 2.75rem; }
.shorts-rail-btn--nav svg { width: 1.5rem; height: 1.5rem; }
/* Bouton actif (j'aime, abonné, lecture auto) : plein, contraste maximal. */
.shorts-rail-btn--on {
background: rgba(255, 255, 255, .92);
color: #0f172a;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .9);
}
.shorts-rail-btn--on:hover:not(:disabled) { background: #fff; }
/* Abonnement : la pastille EST l'avatar de la chaîne. */
.shorts-rail-btn--sub { overflow: visible; }
.shorts-rail-badge {
position: absolute;
right: -2px;
bottom: -2px;
display: flex;
align-items: center;
justify-content: center;
width: 1.05rem;
height: 1.05rem;
border-radius: 9999px;
background: #ef4444;
color: #fff;
font-size: .7rem;
font-weight: 800;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}
.shorts-rail-btn--on .shorts-rail-badge { background: #16a34a; }
@media (min-width: 768px) {
/* Bureau : rail plus compact, la vidéo reste le sujet. */
.shorts-rail-btn {
width: 2.25rem;
height: 2.25rem;
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, .45);
}
.shorts-rail-btn svg { width: 1.05rem; height: 1.05rem; }
.shorts-rail-btn--nav { width: 2.5rem; height: 2.5rem; }
.shorts-rail-btn--nav svg { width: 1.35rem; height: 1.35rem; }
}
/* --- Shorts : panneau d'informations (bouton ⓘ) ---------------------------- */
.shorts-stage { --shorts-panel-w: 0px; }
@media (min-width: 768px) {
/* Le cadre est dimensionné par la hauteur du viewport : on retranche aussi
la largeur du panneau pour que la vidéo RÉTRÉCISSE au lieu d'être rognée
(exigence : « sans masquer la vidéo »). */
.shorts-stage.shorts-panel-open { --shorts-panel-w: 356px; }
}
.shorts-info-action {
display: inline-flex;
align-items: center;
gap: .4rem;
border-radius: 9999px;
padding: .3rem .6rem;
font-size: .72rem;
font-weight: 600;
color: rgba(255, 255, 255, .75);
background: rgba(255, 255, 255, .07);
transition: background .15s ease, color .15s ease;
}
.shorts-info-action:hover:not(:disabled) { background: rgba(255, 255, 255, .16); color: #fff; }
.shorts-info-action:disabled { opacity: .45; }
.shorts-info-action--on { color: #fff; background: rgba(255, 255, 255, .2); }
/* Contrôles flottants (rail + FAB menu) : presque fondu au repos pour laisser
la vidéo respirer (ton pro), pleins dès le moindre mouvement de souris
(chromeVisible) ou survol direct. Le bouton actif et le FAB ouvert ne sont
jamais atténués. Le tactile ne déclenche presque pas mousemove : sur mobile
tout reste donc plein (comportement voulu). */
.shorts-frame .shorts-rail-btn,
.shorts-frame app-player-menu .pm-fab {
transition: opacity 0.25s ease;
}
.shorts-chrome-hidden .shorts-frame .shorts-ctrl {
.shorts-chrome-hidden .shorts-frame .shorts-rail-btn {
opacity: 0.18;
}
.shorts-chrome-hidden .shorts-frame .shorts-ctrl:not(:disabled):hover {
.shorts-chrome-hidden .shorts-frame .shorts-rail-btn:not(:disabled):hover {
opacity: 1;
}
.shorts-chrome-hidden .shorts-frame app-player-menu .pm-fab {
@@ -26,6 +26,9 @@ import { CommonModule } from '@angular/common';
'(wheel)': '$event.stopPropagation()',
'(touchstart)': '$event.stopPropagation()',
'(touchend)': '$event.stopPropagation()',
// /shorts suit aussi le doigt en `touchmove` : sans ça, glisser le FAB
// déplacerait AUSSI le cadre (deux drags pour un geste).
'(touchmove)': '$event.stopPropagation()',
},
template: `
<button type="button"
+179 -30
View File
@@ -1,5 +1,6 @@
<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()"
[class.shorts-panel-open]="infoOpen()"
(click)="closeMenu()" (mousemove)="pokeChrome()">
<!-- Fond d'ambiance : vignette du short courant, floutée (façon YouTube). -->
<div class="shorts-ambient" aria-hidden="true">
@@ -15,13 +16,15 @@
<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)">
<!-- Scène : cadre 9:16 centré + (bureau) panneau d'informations à droite -->
<div class="relative z-10 flex w-full min-h-0 flex-1 items-center justify-center gap-0 px-0 py-0 sm:gap-4 sm:px-4 sm:py-3"
(wheel)="onWheel($event)" (touchstart)="onTouchStart($event)" (touchmove)="onTouchMove($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">
<div class="relative flex min-w-0 flex-1 items-center justify-center" style="max-width: 560px;">
<!-- Video frame (9:16). `translateY` pendant un glissement tactile : tout
le chrome (rail, méta, FAB) reste solidaire de l'image. -->
<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"
[style.transform]="dragOffset() ? 'translateY(' + dragOffset() + 'px)' : null">
@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()"
@@ -116,6 +119,11 @@
}
<span class="pm-item-label">{{ isFullscreen() ? 'Quitter le plein écran' : 'Plein écran' }}</span>
</button>
<button type="button" (click)="toggleAutoNext(); closeMenu()" class="pm-item" [attr.aria-pressed]="autoNext()">
<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">Lecture auto</span>
<span class="pm-item-hint">{{ autoNext() ? 'Oui' : 'Non' }}</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>
@@ -146,22 +154,75 @@
<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>
<!-- Rail d'actions (bord DROIT de la vidéo) : TOUJOURS monté, même sans
vidéo — c'est la seule navigation utilisable dans tous les états.
Ordre : informations, flèches ↑/↓, j'aime, s'abonner, commentaires,
partager, lecture auto. Ancré EN BAS sur mobile (pouce, au-dessus de
la méta), centré verticalement au bureau. `.shorts-rail-btn` porte
le style « verre dépoli » + le fondu au repos (index.css). -->
<div class="shorts-rail absolute right-1.5 bottom-40 z-20 flex flex-col items-center gap-1.5 md:right-2 md:bottom-auto md:top-1/2 md:-translate-y-1/2">
<button type="button" class="shorts-rail-btn" (click)="toggleInfo(); $event.stopPropagation()"
[class.shorts-rail-btn--on]="infoOpen()" [attr.aria-pressed]="infoOpen()"
[title]="infoOpen() ? 'Masquer les informations' : 'Informations sur ce short'"
[attr.aria-label]="infoOpen() ? 'Masquer les informations' : 'Informations sur ce short'">
<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"><circle cx="12" cy="12" r="9.5"/><path d="M12 11v5.5"/><path d="M12 7.6v.6"/></svg>
</button>
<button type="button" class="shorts-rail-btn shorts-rail-btn--nav" (click)="prev(); $event.stopPropagation()"
[disabled]="!canPrev()" title="Short précédent" aria-label="Short précédent">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 14.5l6-6 6 6"/></svg>
</button>
<button type="button" class="shorts-rail-btn shorts-rail-btn--nav" (click)="next(); $event.stopPropagation()"
[disabled]="!canNext()" title="Short suivant" aria-label="Short suivant">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9.5l6 6 6-6"/></svg>
</button>
<button type="button" class="shorts-rail-btn" (click)="toggleLike(); $event.stopPropagation()"
[disabled]="!isLoggedIn() || likeBusy()" [class.shorts-rail-btn--on]="liked()"
[title]="isLoggedIn() ? (liked() ? 'Je n’aime plus' : 'J’aime') : 'Connectez-vous pour aimer'"
[attr.aria-label]="isLoggedIn() ? (liked() ? 'Je n’aime plus' : 'J’aime') : 'Connectez-vous pour aimer'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round" 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>
@if (isLoggedIn() && currentChannelKey() && current(); as v) {
<button type="button" class="shorts-rail-btn shorts-rail-btn--sub" (click)="toggleSubscribe(); $event.stopPropagation()"
[disabled]="subBusy()" [class.shorts-rail-btn--on]="isSubscribed()"
[title]="isSubscribed() ? 'Se désabonner' : ('S’abonner à ' + v.uploaderName)"
[attr.aria-label]="isSubscribed() ? 'Se désabonner' : ('S’abonner à ' + v.uploaderName)">
@if (v.uploaderAvatar) {
<img [src]="v.uploaderAvatar" alt="" class="h-full w-full rounded-full object-cover" loading="lazy" />
}
<span class="shorts-rail-badge" aria-hidden="true">{{ isSubscribed() ? '✓' : '+' }}</span>
</button>
}
@if (watchLink()) {
<a [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="$event.stopPropagation()"
class="shorts-rail-btn" title="Commentaires" aria-label="Commentaires">
<svg xmlns="http://www.w3.org/2000/svg" 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>
}
<button type="button" class="shorts-rail-btn" (click)="share(); $event.stopPropagation()" title="Partager" aria-label="Partager">
<svg xmlns="http://www.w3.org/2000/svg" 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>
<button type="button" class="shorts-rail-btn" (click)="toggleAutoNext(); $event.stopPropagation()"
[class.shorts-rail-btn--on]="autoNext()" [attr.aria-pressed]="autoNext()"
[title]="autoNext() ? 'Lecture auto activée' : 'Lecture auto désactivée'"
[attr.aria-label]="autoNext() ? 'Désactiver la lecture auto' : 'Activer la lecture auto'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 5v14l8-7zM12 5v14l8-7z"/></svg>
</button>
</div>
@if (current(); as v) {
<!-- Badge « Suggéré » : flux personnel (« Pour toi »), en haut au centre. -->
@if (forYou()) {
<div class="shorts-chrome pointer-events-none absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-full bg-black/60 px-2.5 py-1 text-[11px] font-semibold text-white ring-1 ring-white/20 backdrop-blur-md">
Suggéré
</div>
}
<!-- Capteur tactile (mobile uniquement) : l'iframe mange les
touchers, donc le swipe up/down passerait à la trappe. Cette
@@ -175,7 +236,7 @@
<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) -->
<!-- Bas : avatar + titre + méta + fraîcheur (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) {
@@ -205,13 +266,6 @@
</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) {
@@ -229,11 +283,106 @@
</div>
</div>
<!-- Panneau d'informations (bouton ⓘ) : colonne à DROITE de la vidéo au
bureau (la vidéo reste visible, elle rétrécit), tiroir plein écran sur
mobile. Contenu : titre + vues, barre d'engagement, chaîne,
description, commentaires (renvoi vers Watch : le flux Shorts n'a pas
d'API commentaires). -->
@if (infoOpen()) {
<div class="fixed inset-0 z-20 bg-black/60 md:hidden" (click)="toggleInfo()"></div>
<aside class="shorts-info fixed inset-y-0 right-0 z-30 w-[min(340px,88vw)] min-h-0 overflow-y-auto border-l border-white/10 bg-slate-950/95 backdrop-blur-md md:static md:z-10 md:self-stretch md:h-auto md:w-[340px] md:shrink-0 md:rounded-2xl md:border md:border-white/10 md:bg-slate-900/70"
aria-label="Informations sur le short" (click)="$event.stopPropagation()">
@if (current(); as v) {
<div class="p-4">
<div class="flex items-start gap-2">
<h2 class="flex-1 text-sm font-semibold leading-snug text-white">{{ v.title }}</h2>
<button type="button" class="shorts-rail-btn h-8 w-8 shrink-0" (click)="toggleInfo()" title="Fermer" aria-label="Fermer les informations">
<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>
<!-- Barre d'engagement -->
<div class="mt-3 flex items-center gap-2 border-y border-white/10 py-2">
<button type="button" class="shorts-info-action" (click)="toggleLike()" [disabled]="!isLoggedIn() || likeBusy()"
[class.shorts-info-action--on]="liked()" [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" stroke-linejoin="round" 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>
{{ liked() ? 'Aimé' : 'J’aime' }}
</button>
<button type="button" class="shorts-info-action" (click)="share()" 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>
{{ shareCopied() ? 'Lien copié ✓' : 'Partager' }}
</button>
<button type="button" class="shorts-info-action ml-auto" (click)="menuOpen.set(true)" title="Toutes les options" aria-label="Toutes les options">
<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="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>
</div>
<!-- Chaîne -->
<div class="mt-3 flex items-center gap-3">
@if (v.uploaderAvatar) {
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="h-9 w-9 shrink-0 rounded-full object-cover ring-1 ring-white/20" loading="lazy" />
} @else {
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 text-sm font-bold text-white" aria-hidden="true">{{ (v.uploaderName || '?').trim().charAt(0).toUpperCase() }}</div>
}
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-semibold text-white">{{ v.uploaderName }}</div>
<div class="truncate text-[11px] text-white/50">@{{ v.uploaderName }} • {{ providerLabel() }}</div>
</div>
@if (isLoggedIn() && currentChannelKey()) {
<button type="button" (click)="toggleSubscribe()" [disabled]="subBusy()"
class="shrink-0 rounded-full px-3 py-1.5 text-[12px] font-semibold transition-colors disabled:opacity-50"
[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>
<!-- 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>
} @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>
}
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/45">
@if (v.language) { <span>Langue : {{ v.language }}</span> }
@if (v.duration) { <span>Durée : {{ v.duration }} s</span> }
</div>
@if (providerUrl()) {
<a [href]="providerUrl()" target="_blank" rel="noopener"
class="mt-3 inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-white/20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.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"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/></svg>
Ouvrir chez le fournisseur
</a>
}
<!-- Commentaires : le flux Shorts n'a pas d'API commentaires, on
renvoie vers la page Watch qui les affiche. -->
<div class="mt-4 border-t border-white/10 pt-3">
<div class="text-[12px] font-semibold text-white/80">Commentaires</div>
<p class="mt-1 text-[11px] leading-relaxed text-white/45">Les commentaires s’affichent dans la page Watch, avec la description complète.</p>
@if (watchLink()) {
<a [routerLink]="watchLink()" [queryParams]="watchQueryParams()" (click)="toggleInfo()"
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-3 py-2 text-[12px] font-semibold text-black hover:bg-slate-200">
<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>
Voir les commentaires
</a>
}
</div>
</div>
} @else {
<div class="p-4 text-[12px] text-white/60">Aucune vidéo sélectionnée.</div>
}
</aside>
}
</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). -->
retirés) — molette, flèches ↑ ↓, balayage, ou rail ↑/↓ du bord droit. -->
</div>
+151 -10
View File
@@ -40,6 +40,9 @@ const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
/** Clé de persistance du filtre de langues (invité compris, sans backend). */
const LS_SHORTS_LANG_KEY = 'newtube.shorts.langFilter';
/** Clé de persistance de la lecture auto (activée par défaut). */
const LS_SHORTS_AUTONEXT_KEY = 'newtube.shorts.autoNext';
type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string };
/**
@@ -165,6 +168,27 @@ export class WatchShortComponent implements OnDestroy {
ytProgress = signal<number | null>(null);
/** Plein écran actif. */
isFullscreen = signal(false);
/** Panneau « informations » (colonne au bureau, tiroir sur mobile). */
infoOpen = signal(false);
/**
* 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
* fin du player YouTube la respectent — un seul interrupteur.
*/
autoNext = signal<boolean>(((): boolean => {
try { return localStorage.getItem(LS_SHORTS_AUTONEXT_KEY) !== '0'; } catch { return true; }
})());
/** Décalage vertical du cadre pendant un glissement tactile (suivi du doigt). */
readonly dragOffset = signal(0);
/** Description du short courant (« Plus d'infos »), '' si absente. */
readonly currentDescription = computed(() => {
const v = this.current() as (Video & { shortDescription?: string | null }) | null;
try { return String(v?.shortDescription || '').trim(); } 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. */
private slideOutUntil = 0;
readonly isLoggedIn = computed(() => {
try { return !!this.auth.currentUser(); } catch { return false; }
@@ -207,6 +231,8 @@ export class WatchShortComponent implements OnDestroy {
// scroll/swipe helpers
private wheelAccum = 0;
private lastSwipeY: number | null = null;
/** Origine du glissement tactile (suivi du doigt, distinct du swipe court). */
private dragStartY: number | null = null;
private lastScrollTs = 0;
private lastProvider: string | null = null;
@@ -673,6 +699,19 @@ export class WatchShortComponent implements OnDestroy {
if (this.subs.items().length === 0) this.subs.list().subscribe();
this.subs.listGroups().subscribe();
} catch {}
// Transition verticale à chaque changement de short (façon rail de
// pellicule). Animée sur le CADRE seul : un embed cross-origin ne peut pas
// être monté en double sans relancer la lecture du précédent, donc le
// glissement est celui du cadre (le chrome le suit, il est dedans).
effect(() => {
const i = this.index();
if (this.slideFrom < 0) { this.slideFrom = i; return; }
const dir: 1 | -1 = i >= this.slideFrom ? 1 : -1;
const jumped = Math.abs(i - this.slideFrom) > 4;
this.slideFrom = i;
if (jumped) return; // saut de flux (retrait/ajout d'items) : pas de glissade
this.animateFrame(dir);
});
// Pré-validation Rumble (courant + suivant) + reset progression.
effect(() => {
const v = this.current();
@@ -821,6 +860,9 @@ export class WatchShortComponent implements OnDestroy {
} catch {}
this.autoAdvanceKey = '';
if (!v?.videoId) return;
// Interrupteur du rail : coupé ⇒ aucune avance automatique (le minuteur
// vient d'être désarmé, et le toggle relance scheduleAutoAdvance au besoin).
if (!this.autoNext()) return;
// Twitch : l'embed clips est cross-origin et n'expose AUCUNE API d'état —
// on ne peut pas savoir si la vidéo est en pause. Pas d'avance auto pour
// ne jamais changer de vidéo pendant une pause (navigation manuelle).
@@ -869,6 +911,7 @@ export class WatchShortComponent implements OnDestroy {
/** Fin de lecture vue par un player (garde anti-double). */
private advanceFromPlayer(id: string): void {
if (!this.autoNext()) return;
const key = `end:${id}`;
if (this.autoAdvanceKey === key) return;
this.autoAdvanceKey = key;
@@ -1044,6 +1087,20 @@ export class WatchShortComponent implements OnDestroy {
this.menuOpen.set(false);
}
/** Panneau d'informations (bureau : colonne à droite ; mobile : tiroir). */
toggleInfo(): void {
this.infoOpen.update(o => !o);
}
/** Interrupteur « lecture auto » du rail (persisté). */
toggleAutoNext(): void {
const next = !this.autoNext();
this.autoNext.set(next);
try { localStorage.setItem(LS_SHORTS_AUTONEXT_KEY, next ? '1' : '0'); } catch {}
// Réarme (ou désarme) le minuteur de la vidéo courante immédiatement.
try { this.scheduleAutoAdvance(this.current()); } catch {}
}
copyLink(): void {
const url = this.providerUrl() || (typeof window !== 'undefined' ? window.location.href : '');
if (!url) return;
@@ -2002,6 +2059,61 @@ export class WatchShortComponent implements OnDestroy {
}
}
// ---- Glissement vertical : tension, ressort, magnétisme ------------------
/** Vrai si le geste a commencé sur un contrôle (rail, méta, menu, lien). */
private isChromeTarget(target: EventTarget | null): boolean {
const el = target as any;
try { return !!el?.closest?.('.shorts-rail, .shorts-info, app-player-menu, button, a, input'); } catch { return false; }
}
/** Animations désactivées par le système (accessibilité). */
private reducedMotion(): boolean {
try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
}
/**
* Entrée du cadre après un changement de short : le nouveau glisse depuis
* le bas (suivant) ou le haut (précédent). Si un glissement de sortie vient
* d'être lancé (relâchement du doigt), l'entrée attend sa fin — sinon les
* deux animations se superposeraient et la sortie serait invisible.
* Pur visuel : aucun état de lecture n'est touché.
*/
private animateFrame(dir: 1 | -1): void {
try {
const el: any = this.frameRef()?.nativeElement;
if (!el || typeof el.animate !== 'function' || this.reducedMotion()) return;
const h = el.getBoundingClientRect().height || 480;
const delay = Math.max(0, this.slideOutUntil - Date.now());
el.animate(
[{ transform: `translateY(${dir > 0 ? h : -h}px)` }, { transform: 'translateY(0)' }],
{ duration: 260, delay, easing: 'cubic-bezier(.22,.61,.36,1)' },
);
} catch {}
}
/**
* Relâchement du doigt : `dir = 0` = rappel élastique (geste sous le seuil,
* le cadre retombe à sa place) ; sinon le cadre est éjecté dans le sens du
* geste et le short suivant vient s'emboîter (animateFrame).
*/
private settleDrag(dir: 1 | -1 | 0, start: number): void {
if (!start || Math.abs(start) < 4) return;
try {
const el: any = this.frameRef()?.nativeElement;
if (!el || typeof el.animate !== 'function' || this.reducedMotion()) return;
if (dir === 0) {
el.animate([{ transform: `translateY(${start}px)` }, { transform: 'translateY(0)' }],
{ duration: 220, easing: 'cubic-bezier(.22,.61,.36,1)' });
return;
}
const h = el.getBoundingClientRect().height || 480;
el.animate([{ transform: `translateY(${start}px)` }, { transform: `translateY(${-dir * h}px)` }],
{ duration: 160, easing: 'ease-in' });
this.slideOutUntil = Date.now() + 160;
} catch {}
}
next(): void {
if (this.canNext() && this.index() < this.items().length - 1) {
this.index.update(i => i + 1);
@@ -2014,6 +2126,7 @@ export class WatchShortComponent implements OnDestroy {
@HostListener('document:keydown', ['$event'])
onKey(ev: KeyboardEvent) {
this.onUserGesture();
if (ev.key === 'Escape' && this.infoOpen()) { this.infoOpen.set(false); return; }
// Menu FAB ouvert : ↑↓ doivent défiler PANNEAU, pas changer de short.
if (this.items().length === 0 || this.menuOpen()) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.next(); }
@@ -2041,19 +2154,47 @@ export class WatchShortComponent implements OnDestroy {
}
onTouchStart(ev: TouchEvent) {
if (this.items().length === 0) return;
this.lastSwipeY = (ev.changedTouches?.[0]?.clientY ?? null);
// Un geste démarré sur un contrôle (rail, menu, méta, lien) n'est pas un
// swipe : sans cette garde, taper « J'aime » ou une flèche déclenchait
// aussi play/pause (le touchend remontait jusqu'ici).
if (this.items().length === 0 || this.isChromeTarget(ev.target)) {
this.lastSwipeY = null;
this.dragStartY = null;
return;
}
const y = ev.changedTouches?.[0]?.clientY ?? null;
this.lastSwipeY = y;
this.dragStartY = y;
}
/** Suivi du doigt : le cadre (et tout son chrome) glisse avec le geste. */
onTouchMove(ev: TouchEvent) {
if (this.dragStartY == null || this.items().length === 0) return;
const y = ev.touches?.[0]?.clientY;
if (y == null) return;
const dy = y - this.dragStartY;
// Extrémité du flux : le geste résiste (pas d'éjection dans le vide).
const blocked = (dy > 0 && !this.canPrev()) || (dy < 0 && !this.canNext());
this.dragOffset.set(blocked ? dy * 0.3 : dy);
}
onTouchEnd(ev: TouchEvent) {
if (this.items().length === 0) return;
const y = ev.changedTouches?.[0]?.clientY;
if (this.lastSwipeY == null || y == null) return;
const dy = y - this.lastSwipeY;
const threshold = 50;
if (dy < -threshold) this.next();
else if (dy > threshold) this.prev();
else if (Math.abs(dy) < 12) this.onVideoTap();
const start = this.lastSwipeY;
const offset = this.dragOffset();
this.lastSwipeY = null;
this.dragStartY = null;
this.dragOffset.set(0);
if (start == null || this.items().length === 0) return;
const y = ev.changedTouches?.[0]?.clientY;
if (y == null) return;
const dy = y - start;
const h = this.frameRef()?.nativeElement?.getBoundingClientRect?.().height || 480;
// Seuil de bascule ≈ 25 % de la hauteur du cadre (min 60 px) : sous ce
// seuil, ressort (le short revient) ; au-delà, magnétisme (il s'emboîte).
const threshold = Math.max(60, h * 0.25);
if (dy < -threshold && this.canNext()) { this.settleDrag(1, offset); this.next(); return; }
if (dy > threshold && this.canPrev()) { this.settleDrag(-1, offset); this.prev(); return; }
if (Math.abs(dy) < 12) { this.onVideoTap(); return; }
this.settleDrag(0, offset);
}
}