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.
212 lines
8.4 KiB
TypeScript
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);
|
|
}
|
|
}
|