Files
NewTube/src/app/shared/components/player-menu/player-menu.component.ts
T
bruno 99e01c0679
CI / build-and-test (push) Canceled after 8s
feat(shorts): UI/UX — rail d'actions à droite (flèches ↑/↓), panneau d'infos, glissement magnétique
- 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.
2026-10-02 13:37:50 -04:00

212 lines
8.4 KiB
TypeScript

import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Bouton-menu flottant (FAB) partagé entre /watch et /shorts.
*
* Pastille circulaire translucide en haut à gauche du lecteur (façon commandes
* overlay des players YouTube/Dailymotion) qui ouvre un panneau déroulant en
* « verre dépoli » avec en-tête (titre + fermeture) et contenu projeté via
* `ng-content`. Animation d'ouverture/fermeture fluide (fondu + glissement +
* échelle), fermeture au clic extérieur et à Échap.
*
* Usage : `<app-player-menu [(open)]="menuOpen"> …items… </app-player-menu>`
* dans un conteneur `position: relative` (le `:host` se place en absolu).
*/
@Component({
selector: 'app-player-menu',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
// Les pages /shorts hijackent `window:wheel` / `window:touchstart|end` pour
// naviguer d'un short à l'autre : sans ces stopPropagation, tout geste SUR le
// panneau ouvert scrolle la vidéo au lieu du menu (impossible d'atteindre le
// bas du menu). Arrêté ici = corrigé une fois pour /watch ET /shorts.
host: {
'(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"
(click)="toggle(); $event.stopPropagation()"
(pointerdown)="onPointerDown($event)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerUp()"
(pointercancel)="onPointerUp()"
[attr.aria-expanded]="open"
aria-haspopup="dialog"
[attr.aria-label]="title"
[title]="title + ' — glisser pour déplacer'"
class="pm-fab" [class.pm-fab-open]="open">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/>
<line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/>
<line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/>
<line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>
</svg>
</button>
<div class="pm-panel" [class.pm-panel-open]="open" role="dialog" [attr.aria-label]="title" (click)="$event.stopPropagation()">
<div class="pm-panel-header">
<span class="pm-panel-title">{{ title }}</span>
<button type="button" class="pm-panel-close" (click)="close()" aria-label="Fermer">
<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="pm-panel-body">
<ng-content></ng-content>
</div>
</div>
`,
styles: [
`:host { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 30; display: block; }`,
],
})
export class PlayerMenuComponent implements AfterViewInit {
@Input() open = false;
@Input() title = 'Options';
@Output() openChange = new EventEmitter<boolean>();
constructor(private el: ElementRef<HTMLElement>) {}
toggle(): void {
// Un glissement se termine par un clic : on ne doit pas ouvrir le menu.
if (Date.now() < this.suppressClickUntil) return;
this.openChange.emit(!this.open);
}
close(): void {
if (this.open) this.openChange.emit(false);
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: Event): void {
if (!this.open) return;
const target = event.target as Node | null;
if (target && this.el.nativeElement.contains(target)) return;
this.close();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
// -------------------------------------------------------------------------
// Déplacement du FAB (glisser-déposer dans le lecteur)
//
// Position stockée en % du conteneur : elle suit la taille du cadre (desktop
// grand écran comme mobile) et est persistée (localStorage) — c'est un
// réglage de confort, pas un état de lecture.
// -------------------------------------------------------------------------
private readonly POS_KEY = 'newtube:player-menu.pos';
private dragging = false;
private moved = false;
private startX = 0;
private startY = 0;
private baseLeft = 0;
private baseTop = 0;
private suppressClickUntil = 0;
ngAfterViewInit(): void {
this.restorePos();
}
onPointerDown(ev: PointerEvent): void {
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
const host = this.el.nativeElement;
const parent = host.offsetParent as HTMLElement | null;
if (!parent) return;
const hr = host.getBoundingClientRect();
const pr = parent.getBoundingClientRect();
// On fige la position courante en px : le CSS peut ancrer à gauche OU à
// droite (le cadre /shorts inverse l'ancrage), l'inline prime sur les deux.
host.style.left = `${hr.left - pr.left}px`;
host.style.top = `${hr.top - pr.top}px`;
host.style.right = 'auto';
host.style.bottom = 'auto';
this.dragging = true;
this.moved = false;
this.startX = ev.clientX;
this.startY = ev.clientY;
this.baseLeft = hr.left - pr.left;
this.baseTop = hr.top - pr.top;
try { (ev.currentTarget as HTMLElement | null)?.setPointerCapture(ev.pointerId); } catch {}
}
onPointerMove(ev: PointerEvent): void {
if (!this.dragging) return;
const dx = ev.clientX - this.startX;
const dy = ev.clientY - this.startY;
// Seuil anti-clic : en dessous, c'est un clic, pas un glissement.
if (!this.moved && Math.hypot(dx, dy) < 6) return;
this.moved = true;
const host = this.el.nativeElement;
const parent = host.offsetParent as HTMLElement | null;
if (!parent) return;
const pr = parent.getBoundingClientRect();
const hr = host.getBoundingClientRect();
const left = Math.min(Math.max(0, this.baseLeft + dx), Math.max(0, pr.width - hr.width));
const top = Math.min(Math.max(0, this.baseTop + dy), Math.max(0, pr.height - hr.height));
host.style.left = `${left}px`;
host.style.top = `${top}px`;
}
onPointerUp(): void {
if (!this.dragging) return;
this.dragging = false;
if (!this.moved) return;
this.suppressClickUntil = Date.now() + 400;
this.persistPos();
}
/** Persiste la position (en %) et réoriente le panneau vers l'intérieur. */
private persistPos(): void {
const host = this.el.nativeElement;
const parent = host.offsetParent as HTMLElement | null;
if (!parent) return;
const pr = parent.getBoundingClientRect();
const hr = host.getBoundingClientRect();
if (pr.width <= 0 || pr.height <= 0) return;
const x = ((hr.left - pr.left) / pr.width) * 100;
const y = ((hr.top - pr.top) / pr.height) * 100;
try { localStorage.setItem(this.POS_KEY, JSON.stringify({ x, y })); } catch {}
host.style.left = `${x}%`;
host.style.top = `${y}%`;
this.applyAnchor(x, y);
}
/** Restaure la position enregistrée (aucun stockage = position CSS). */
private restorePos(): void {
let pos: { x: number; y: number } | null = null;
try {
const raw = localStorage.getItem(this.POS_KEY);
const parsed = raw ? JSON.parse(raw) : null;
if (parsed && Number.isFinite(parsed?.x) && Number.isFinite(parsed?.y)) pos = parsed;
} catch {}
if (!pos) return;
const host = this.el.nativeElement;
host.style.left = `${pos.x}%`;
host.style.top = `${pos.y}%`;
host.style.right = 'auto';
host.style.bottom = 'auto';
this.applyAnchor(pos.x, pos.y);
}
/**
* Le panneau doit toujours s'ouvrir vers l'INTÉRIEUR du conteneur (sinon
* `overflow: hidden` du cadre /shorts le rogne) : classe selon le demi-espace.
*/
private applyAnchor(x: number, y: number): void {
const host = this.el.nativeElement;
host.classList.toggle('pm-opens-left', x > 50);
host.classList.toggle('pm-opens-right', x <= 50);
host.classList.toggle('pm-opens-up', y > 45);
}
}