feat(shorts): UI/UX — rail d'actions à droite (flèches ↑/↓), panneau d'infos, glissement magnétique
CI / build-and-test (push) Canceled after 8s
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user