feat(menu-lecteur): refonte UI/UX professionnelle du menu FAB
CI / build-and-test (push) Successful in 14m57s
CI / build-and-test (push) Successful in 14m57s
- FAB : pastille verre depoli 40px + icone sliders (standard options) - Panneau : en-tete (titre + fermer), verre depoli, ombre profonde, coins arrondis - Animations ouverture/fermeture (fondu + slide + scale), fermeture Echap + clic exterieur - Items : hover/active/focus, icones teintees, sections + separateurs raffinés - Playlist : modale rendue HORS du panneau (backdrop-filter piegeait position:fixed)
This commit is contained in:
@@ -573,35 +573,143 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- Player FAB menu (shared /watch & /shorts) ---------------------------- */
|
/* --- Player FAB menu (shared /watch & /shorts) ---------------------------- */
|
||||||
/* Items/coque du panneau du menu flottant : petits, translucides, harmonisés
|
/* Coque du menu flottant : pastille « verre dépoli », panneau en élévation
|
||||||
avec les commandes overlay des players YouTube/Dailymotion. */
|
avec en-tête, entrée/sortie animées. Harmonisé avec les commandes overlay
|
||||||
|
des players YouTube/Dailymotion (fond sombre translucide, blur, ring fin). */
|
||||||
|
|
||||||
|
/* Pastille FAB (haut-gauche du lecteur). */
|
||||||
|
.pm-fab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem; /* 40px */
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, .55);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .14);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
|
||||||
|
}
|
||||||
|
.pm-fab svg { width: 1.125rem; height: 1.125rem; }
|
||||||
|
.pm-fab:hover { background: rgba(0, 0, 0, .72); border-color: rgba(255, 255, 255, .24); }
|
||||||
|
.pm-fab:active { transform: scale(.93); }
|
||||||
|
.pm-fab-open { background: rgba(0, 0, 0, .72); border-color: rgba(255, 255, 255, .24); }
|
||||||
|
.pm-fab:focus-visible { outline: 2px solid rgba(255, 255, 255, .55); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Panneau déroulant (toujours monté, animé via open/close). */
|
||||||
|
.pm-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
left: 0;
|
||||||
|
width: 18rem; /* 288px */
|
||||||
|
max-height: min(70vh, 480px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(15, 23, 42, .92); /* slate-900/92 */
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, .55), 0 2px 10px rgba(0, 0, 0, .35);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px) scale(.97);
|
||||||
|
transform-origin: top left;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .18s ease, transform .18s cubic-bezier(.2, .9, .3, 1), visibility 0s linear .18s;
|
||||||
|
}
|
||||||
|
.pm-panel-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity .18s ease, transform .18s cubic-bezier(.2, .9, .3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En-tête : titre + bouton fermer. */
|
||||||
|
.pm-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.625rem 0.625rem 0.875rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pm-panel-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
color: rgba(255, 255, 255, .92);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.pm-panel-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: rgba(255, 255, 255, .7);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.pm-panel-close svg { width: 1rem; height: 1rem; }
|
||||||
|
.pm-panel-close:hover { background: rgba(255, 255, 255, .1); color: #fff; }
|
||||||
|
.pm-panel-close:focus-visible { outline: 2px solid rgba(255, 255, 255, .5); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Corps scrollable du panneau. */
|
||||||
|
.pm-panel-body {
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.pm-panel-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.pm-panel-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .18); border-radius: 9999px; }
|
||||||
|
|
||||||
|
/* Libellé de section. */
|
||||||
.pm-section-label {
|
.pm-section-label {
|
||||||
padding: 0.375rem 0.75rem 0.25rem;
|
padding: 0.625rem 0.75rem 0.375rem;
|
||||||
font-size: 0.6875rem; /* 11px */
|
font-size: 0.6875rem; /* 11px */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Élément de menu. */
|
||||||
.pm-item {
|
.pm-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.75rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5625rem 0.75rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.625rem;
|
||||||
font-size: 0.8125rem; /* 13px */
|
font-size: 0.84375rem; /* 13.5px */
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1.25;
|
line-height: 1.3;
|
||||||
transition: background-color .15s ease;
|
cursor: pointer;
|
||||||
|
transition: background-color .12s ease, color .12s ease;
|
||||||
}
|
}
|
||||||
.pm-item:hover { background-color: rgba(255, 255, 255, .08); }
|
.pm-item:hover { background-color: rgba(255, 255, 255, .08); }
|
||||||
|
.pm-item:active { background-color: rgba(255, 255, 255, .12); }
|
||||||
|
.pm-item:focus-visible { outline: 2px solid rgba(255, 255, 255, .35); outline-offset: -2px; }
|
||||||
.pm-item:disabled { opacity: .4; cursor: not-allowed; }
|
.pm-item:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
.pm-item svg { flex-shrink: 0; }
|
.pm-item svg { flex-shrink: 0; color: rgba(255, 255, 255, .75); }
|
||||||
.pm-item .pm-item-label { flex: 1; min-width: 0; }
|
.pm-item .pm-item-label { flex: 1; min-width: 0; }
|
||||||
.pm-item .pm-item-hint { font-size: 0.6875rem; color: var(--muted); }
|
.pm-item .pm-item-hint { font-size: 0.71875rem; color: var(--muted); }
|
||||||
.pm-sep { height: 1px; margin: 0.375rem 0.5rem; background: rgba(255, 255, 255, .12); }
|
|
||||||
|
/* Séparateur. */
|
||||||
|
.pm-sep { height: 1px; margin: 0.375rem 0.625rem; background: rgba(255, 255, 255, .08); }
|
||||||
|
|
||||||
/* --- Drawer / overlay plumbing -------------------------------------------- */
|
/* --- Drawer / overlay plumbing -------------------------------------------- */
|
||||||
/* Applied to <body> while a full-screen drawer or modal owns the viewport.
|
/* Applied to <body> while a full-screen drawer or modal owns the viewport.
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { CommonModule } from '@angular/common';
|
|||||||
/**
|
/**
|
||||||
* Bouton-menu flottant (FAB) partagé entre /watch et /shorts.
|
* Bouton-menu flottant (FAB) partagé entre /watch et /shorts.
|
||||||
*
|
*
|
||||||
* Pastille circulaire translucide en haut à gauche du lecteur, façon
|
* Pastille circulaire translucide en haut à gauche du lecteur (façon commandes
|
||||||
* commandes overlay des players YouTube/Dailymotion : elle ouvre un panneau
|
* overlay des players YouTube/Dailymotion) qui ouvre un panneau déroulant en
|
||||||
* déroulant qui reçoit son contenu par projection (`ng-content`) — chaque page
|
* « verre dépoli » avec en-tête (titre + fermeture) et contenu projeté via
|
||||||
* fournit ses propres options, la coque (position, style, open/close, clic
|
* `ng-content`. Animation d'ouverture/fermeture fluide (fondu + glissement +
|
||||||
* extérieur) est identique partout.
|
* échelle), fermeture au clic extérieur et à Échap.
|
||||||
*
|
*
|
||||||
* Usage : `<app-player-menu [(open)]="menuOpen"> …items… </app-player-menu>`
|
* Usage : `<app-player-menu [(open)]="menuOpen"> …items… </app-player-menu>`
|
||||||
* dans un conteneur `position: relative` (le `:host` se place en absolu).
|
* dans un conteneur `position: relative` (le `:host` se place en absolu).
|
||||||
@@ -22,20 +22,29 @@ import { CommonModule } from '@angular/common';
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
(click)="toggle(); $event.stopPropagation()"
|
(click)="toggle(); $event.stopPropagation()"
|
||||||
[attr.aria-expanded]="open"
|
[attr.aria-expanded]="open"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="dialog"
|
||||||
aria-label="Menu"
|
[attr.aria-label]="title"
|
||||||
title="Menu"
|
[title]="title"
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40">
|
class="pm-fab" [class.pm-fab-open]="open">
|
||||||
<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" stroke-linecap="round" aria-hidden="true">
|
<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">
|
||||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@if (open) {
|
|
||||||
<div class="absolute left-0 top-11 max-h-[70vh] w-64 overflow-y-auto overscroll-contain rounded-xl bg-black/70 p-1.5 shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
|
<div class="pm-panel" [class.pm-panel-open]="open" role="dialog" [attr.aria-label]="title" (click)="$event.stopPropagation()">
|
||||||
role="menu" (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>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`:host { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 30; display: block; }`,
|
`:host { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 30; display: block; }`,
|
||||||
@@ -43,6 +52,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
})
|
})
|
||||||
export class PlayerMenuComponent {
|
export class PlayerMenuComponent {
|
||||||
@Input() open = false;
|
@Input() open = false;
|
||||||
|
@Input() title = 'Options';
|
||||||
@Output() openChange = new EventEmitter<boolean>();
|
@Output() openChange = new EventEmitter<boolean>();
|
||||||
|
|
||||||
constructor(private el: ElementRef<HTMLElement>) {}
|
constructor(private el: ElementRef<HTMLElement>) {}
|
||||||
@@ -62,4 +72,9 @@ export class PlayerMenuComponent {
|
|||||||
if (target && this.el.nativeElement.contains(target)) return;
|
if (target && this.el.nativeElement.contains(target)) return;
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { forkJoin } from 'rxjs';
|
|||||||
<div [class]="menuItem ? 'relative w-full text-left' : 'relative inline-block text-left'" (click)="$event.stopPropagation();">
|
<div [class]="menuItem ? 'relative w-full text-left' : 'relative inline-block text-left'" (click)="$event.stopPropagation();">
|
||||||
<button (click)="toggleOpen($event)"
|
<button (click)="toggleOpen($event)"
|
||||||
[disabled]="!isAuthenticated()"
|
[disabled]="!isAuthenticated()"
|
||||||
|
[style.display]="triggerHidden ? 'none' : ''"
|
||||||
[class]="menuItem ? 'pm-item' : 'flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 disabled:opacity-60 disabled:cursor-not-allowed px-4 py-2 rounded-full transition text-slate-200 font-semibold'"
|
[class]="menuItem ? 'pm-item' : 'flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 disabled:opacity-60 disabled:cursor-not-allowed px-4 py-2 rounded-full transition text-slate-200 font-semibold'"
|
||||||
[attr.aria-expanded]="open()"
|
[attr.aria-expanded]="open()"
|
||||||
[attr.aria-haspopup]="true"
|
[attr.aria-haspopup]="true"
|
||||||
@@ -121,6 +122,8 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
|
|||||||
@Input() thumbnail?: string;
|
@Input() thumbnail?: string;
|
||||||
/** Rendu « élément de menu » (menu FAB du lecteur) : plein largeur, icon + label. */
|
/** Rendu « élément de menu » (menu FAB du lecteur) : plein largeur, icon + label. */
|
||||||
@Input() menuItem = false;
|
@Input() menuItem = false;
|
||||||
|
/** Masque le bouton déclencheur (déclenchement externe via `open()`). */
|
||||||
|
@Input() triggerHidden = false;
|
||||||
|
|
||||||
open = signal<boolean>(false);
|
open = signal<boolean>(false);
|
||||||
loading = signal<boolean>(false);
|
loading = signal<boolean>(false);
|
||||||
@@ -155,6 +158,15 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
|
|||||||
this.selected.set(new Set());
|
this.selected.set(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ouvre la boîte de dialogue (déclenchement externe, ex. menu FAB). */
|
||||||
|
openDialog(): void {
|
||||||
|
if (!this.isAuthenticated()) return;
|
||||||
|
this.open.set(true);
|
||||||
|
if (this.playlists().length === 0 && !this.loading()) {
|
||||||
|
this.reloadPlaylists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reloadPlaylists(): void {
|
reloadPlaylists(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|||||||
@@ -47,7 +47,13 @@
|
|||||||
@if (likeError(); as lerr) {
|
@if (likeError(); as lerr) {
|
||||||
<div class="px-3 py-1.5 text-[11px] text-red-300">{{ lerr }}</div>
|
<div class="px-3 py-1.5 text-[11px] text-red-300">{{ lerr }}</div>
|
||||||
}
|
}
|
||||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail" [menuItem]="true"></app-add-to-playlist>
|
<button type="button" (click)="openPlaylist()" class="pm-item">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M4 6a2 2 0 012-2h12a1 1 0 010 2H6v10a1 1 0 11-2 0V6z" />
|
||||||
|
<path d="M14 11a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM14 15a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM10 11a1 1 0 112 0v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H9a1 1 0 110-2h1v-1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="pm-item-label">Ajouter à une playlist</span>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button" (click)="toggleTranscript(); closeMenu()" class="pm-item" [attr.aria-expanded]="transcriptOpen()">
|
<button type="button" (click)="toggleTranscript(); closeMenu()" class="pm-item" [attr.aria-expanded]="transcriptOpen()">
|
||||||
@@ -104,6 +110,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Boîte de dialogue playlist : rendue HORS du menu FAB (le panneau a un
|
||||||
|
`backdrop-filter` qui créerait un bloc de positionnement pour la modale
|
||||||
|
`position: fixed`). Déclencheur masqué, ouvert via openPlaylist(). -->
|
||||||
|
<app-add-to-playlist #addToPlaylist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail" [triggerHidden]="true"></app-add-to-playlist>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<!-- Squelette des métadonnées pendant /details (le player joue déjà) -->
|
<!-- Squelette des métadonnées pendant /details (le player joue déjà) -->
|
||||||
<div class="mt-4 animate-pulse space-y-3" aria-hidden="true">
|
<div class="mt-4 animate-pulse space-y-3" aria-hidden="true">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
private subs = inject(SubscriptionsService);
|
private subs = inject(SubscriptionsService);
|
||||||
private routeSubscription!: Subscription;
|
private routeSubscription!: Subscription;
|
||||||
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
|
@ViewChild('embedFrame', { static: false }) embedFrame?: ElementRef<HTMLIFrameElement>;
|
||||||
|
@ViewChild('addToPlaylist', { static: false }) addToPlaylist?: AddToPlaylistComponent;
|
||||||
private attachTryCount = 0;
|
private attachTryCount = 0;
|
||||||
|
|
||||||
// --- Chaîne / abonnement ---
|
// --- Chaîne / abonnement ---
|
||||||
@@ -167,6 +168,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
closeMenu(): void {
|
closeMenu(): void {
|
||||||
this.menuOpen.set(false);
|
this.menuOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ouvre la boîte de dialogue playlist (composant rendu hors du menu FAB). */
|
||||||
|
openPlaylist(): void {
|
||||||
|
this.closeMenu();
|
||||||
|
this.addToPlaylist?.openDialog();
|
||||||
|
}
|
||||||
// Prefer full description if available; otherwise fallback to shortDescription
|
// Prefer full description if available; otherwise fallback to shortDescription
|
||||||
descriptionText = computed(() => {
|
descriptionText = computed(() => {
|
||||||
const v = this.video();
|
const v = this.video();
|
||||||
|
|||||||
Reference in New Issue
Block a user