fix(shorts): embed PeerTube sur l'instance d'origine + FAB glissable + layout bureau
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
- PeerTube : l'adaptateur pt.ts reconstruisait l'item SANS `url` et le pont catalogue ne conservait l'URL que pour Odysee → /shorts reconstruisait l'embed sur l'instance ACTIVE → « Sorry this video does not exist » sur toute vidéo fédérée. `url` est désormais miroitée dans VideoItem (contrat v2), propagée par PtAdapter et gardée par catalogShortToVideo : peerTubeHostOf() dérive enfin le host d'origine. Vérifié de bout en bout : embed tube.blueben.net → 200 + vrai titre, cas de test dédié dans shorts-catalog.spec. - FAB du menu : déplaçable au pointeur (seuil 6 px, position en % du cadre, persistée en localStorage). Le clic qui suit un glissement n'ouvre pas le menu, et le panneau s'ouvre TOUJOURS vers l'intérieur du cadre (classes pm-opens-left / pm-opens-right / pm-opens-up posées par applyAnchor) : sinon overflow:hidden le rognait dès qu'on déplaçait le bouton. - Bureau : --shorts-chrome 9rem → 4rem (la vidéo passe de 691 à 771 px de haut en 1280x900, 389 → 434 de large) et menu compact au-delà de 768 px (FAB 36 → 30 px, panneau 233 → 202 px). Mobile inchangé (36 / 636 px). Tests : test:shorts-catalog (nouveau cas PeerTube), test:kind, test:section, test:search verts ; build ng OK ; sondage Playwright 19/19 sur l'instance locale (layout, glissement + restauration, embed PeerTube 200 sans erreur).
This commit is contained in:
@@ -502,8 +502,11 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
/* Bureau : pas de barre du bas, les flèches sont sur les côtés — on ne
|
||||||
|
réserve que la scène (py-3) + la charge « Chargement… ». La vidéo gagne
|
||||||
|
~80 px de hauteur (et autant de largeur, 9:16 oblige). */
|
||||||
.shorts-frame {
|
.shorts-frame {
|
||||||
--shorts-chrome: 9rem;
|
--shorts-chrome: 4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
@@ -581,6 +584,8 @@ body {
|
|||||||
relief (ombre + highlight interne) et agrandissement au survol. */
|
relief (ombre + highlight interne) et agrandissement au survol. */
|
||||||
.pm-fab {
|
.pm-fab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
touch-action: none; /* glisser le FAB ne défile pas la page */
|
||||||
|
cursor: grab;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.25rem; /* 36px */
|
width: 2.25rem; /* 36px */
|
||||||
@@ -601,7 +606,7 @@ body {
|
|||||||
border-color: rgba(255, 255, 255, .32);
|
border-color: rgba(255, 255, 255, .32);
|
||||||
transform: scale(1.06);
|
transform: scale(1.06);
|
||||||
}
|
}
|
||||||
.pm-fab:active { transform: scale(.94); }
|
.pm-fab:active { transform: scale(.94); cursor: grabbing; }
|
||||||
.pm-fab-open {
|
.pm-fab-open {
|
||||||
background: linear-gradient(180deg, rgba(51, 65, 85, .95), rgba(2, 6, 23, .95));
|
background: linear-gradient(180deg, rgba(51, 65, 85, .95), rgba(2, 6, 23, .95));
|
||||||
border-color: rgba(255, 255, 255, .32);
|
border-color: rgba(255, 255, 255, .32);
|
||||||
@@ -730,6 +735,25 @@ body {
|
|||||||
.shorts-frame app-player-menu { left: auto; right: 0.5rem; }
|
.shorts-frame app-player-menu { left: auto; right: 0.5rem; }
|
||||||
.shorts-frame app-player-menu .pm-panel { left: auto; right: 0; transform-origin: top right; }
|
.shorts-frame app-player-menu .pm-panel { left: auto; right: 0; transform-origin: top right; }
|
||||||
|
|
||||||
|
/* FAB glissé ailleurs que son coin d'origine : le panneau s'ouvre TOUJOURS
|
||||||
|
vers l'intérieur du conteneur (sinon `overflow: hidden` le rogne) et vers
|
||||||
|
le haut quand le FAB est en bas. Les classes sont posées par
|
||||||
|
PlayerMenuComponent.applyAnchor(). */
|
||||||
|
app-player-menu.pm-opens-left .pm-panel { left: auto; right: 0; transform-origin: top right; }
|
||||||
|
app-player-menu.pm-opens-right .pm-panel { left: 0; right: auto; transform-origin: top left; }
|
||||||
|
app-player-menu.pm-opens-up .pm-panel { top: auto; bottom: calc(100% + 0.5rem); transform-origin: bottom left; }
|
||||||
|
app-player-menu.pm-opens-left.pm-opens-up .pm-panel { transform-origin: bottom right; }
|
||||||
|
|
||||||
|
/* Bureau : menu plus compact pour laisser la place à la vidéo (shorts). */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.shorts-frame app-player-menu .pm-fab { width: 1.875rem; height: 1.875rem; }
|
||||||
|
.shorts-frame app-player-menu .pm-fab svg { width: 0.875rem; height: 0.875rem; }
|
||||||
|
.shorts-frame app-player-menu .pm-panel { width: 13rem; max-height: min(60vh, 380px); }
|
||||||
|
.shorts-frame app-player-menu .pm-item { padding: 0.375rem 0.5rem; gap: 0.5rem; font-size: 0.75rem; }
|
||||||
|
.shorts-frame app-player-menu .pm-section-label { padding: 0.375rem 0.5rem 0.125rem; font-size: 0.625rem; }
|
||||||
|
.shorts-frame app-player-menu .pm-panel-header { padding: 0.3125rem 0.3125rem 0.3125rem 0.625rem; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- 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.
|
||||||
Note: deliberately NOT `touch-action: none` — touch-action is intersected up
|
Note: deliberately NOT `touch-action: none` — touch-action is intersected up
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
// Le serveur envoie `publishedAt` (ISO) quand l'instance l'expose.
|
// Le serveur envoie `publishedAt` (ISO) quand l'instance l'expose.
|
||||||
publishedAt: typeof it.publishedAt === 'string' ? it.publishedAt : undefined,
|
publishedAt: typeof it.publishedAt === 'string' ? it.publishedAt : undefined,
|
||||||
channelExternalId,
|
channelExternalId,
|
||||||
|
// URL canonnique : instance d'origine (vidéos fédérées). Sans elle,
|
||||||
|
// /shorts reconstruit l'embed sur l'instance ACTIVE → « Sorry this
|
||||||
|
// video does not exist ».
|
||||||
|
url: typeof it.url === 'string' ? it.url : undefined,
|
||||||
// Signaux de type pour le raffinement client des filtres.
|
// Signaux de type pour le raffinement client des filtres.
|
||||||
type: typeof it.type === 'string' ? it.type : undefined,
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
isShort: it.isShort === true ? true : undefined,
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ assertEqual(
|
|||||||
assertEqual(v?.views, 0, 'vues inconnues → 0 (pas de devinette)');
|
assertEqual(v?.views, 0, 'vues inconnues → 0 (pas de devinette)');
|
||||||
assertEqual(v?.duration, 0, 'durée inconnue → 0');
|
assertEqual(v?.duration, 0, 'durée inconnue → 0');
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// PeerTube fédéré : l'URL porte l'instance d'origine. Sans elle, /shorts
|
||||||
|
// reconstruit l'embed sur l'instance ACTIVE → « Sorry this video does not exist ».
|
||||||
|
const v: any = catalogShortToVideo({
|
||||||
|
id: 'p1', provider: 'peertube', title: 'P', thumbnailUrl: '', channelName: 'C',
|
||||||
|
url: 'https://makertube.net/videos/watch/p1',
|
||||||
|
} as any);
|
||||||
|
assertEqual(v?.url, 'https://makertube.net/videos/watch/p1', 'url fournisseur conservée (host PeerTube)');
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const v: any = catalogShortToVideo({
|
const v: any = catalogShortToVideo({
|
||||||
id: 'y1', provider: 'youtube', title: 'Y', thumbnailUrl: '', channelName: 'C',
|
id: 'y1', provider: 'youtube', title: 'Y', thumbnailUrl: '', channelName: 'C',
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export function catalogShortToVideo(item: VideoItem | null | undefined): Video |
|
|||||||
const embeddableRaw = (item as { embeddable?: unknown }).embeddable;
|
const embeddableRaw = (item as { embeddable?: unknown }).embeddable;
|
||||||
const allowEmbed = typeof embeddableRaw === 'boolean' ? embeddableRaw : undefined;
|
const allowEmbed = typeof embeddableRaw === 'boolean' ? embeddableRaw : undefined;
|
||||||
return {
|
return {
|
||||||
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : '',
|
// Odysee : URL normalisée depuis son slug ; ailleurs on conserve l'URL
|
||||||
|
// du fournisseur (PeerTube fédéré en a besoin pour choisir le bon host).
|
||||||
|
url: provider === 'odysee' && slug ? `https://odysee.com/${slug}` : strOrEmpty(item.url),
|
||||||
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
type: item.isLive === true ? 'live' : (item.isShort === true ? 'short' : 'video'),
|
||||||
title: strOrEmpty(item.title),
|
title: strOrEmpty(item.title),
|
||||||
thumbnail: strOrEmpty(item.thumbnailUrl),
|
thumbnail: strOrEmpty(item.thumbnailUrl),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
|
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,10 +30,14 @@ import { CommonModule } from '@angular/common';
|
|||||||
template: `
|
template: `
|
||||||
<button type="button"
|
<button type="button"
|
||||||
(click)="toggle(); $event.stopPropagation()"
|
(click)="toggle(); $event.stopPropagation()"
|
||||||
|
(pointerdown)="onPointerDown($event)"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerUp()"
|
||||||
|
(pointercancel)="onPointerUp()"
|
||||||
[attr.aria-expanded]="open"
|
[attr.aria-expanded]="open"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
[attr.aria-label]="title"
|
[attr.aria-label]="title"
|
||||||
[title]="title"
|
[title]="title + ' — glisser pour déplacer'"
|
||||||
class="pm-fab" [class.pm-fab-open]="open">
|
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">
|
<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="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/>
|
||||||
@@ -59,7 +63,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
`: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; }`,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PlayerMenuComponent {
|
export class PlayerMenuComponent implements AfterViewInit {
|
||||||
@Input() open = false;
|
@Input() open = false;
|
||||||
@Input() title = 'Options';
|
@Input() title = 'Options';
|
||||||
@Output() openChange = new EventEmitter<boolean>();
|
@Output() openChange = new EventEmitter<boolean>();
|
||||||
@@ -67,6 +71,8 @@ export class PlayerMenuComponent {
|
|||||||
constructor(private el: ElementRef<HTMLElement>) {}
|
constructor(private el: ElementRef<HTMLElement>) {}
|
||||||
|
|
||||||
toggle(): void {
|
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);
|
this.openChange.emit(!this.open);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +92,117 @@ export class PlayerMenuComponent {
|
|||||||
onEscape(): void {
|
onEscape(): void {
|
||||||
this.close();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface VideoItem {
|
|||||||
/** ISO 8601. */
|
/** ISO 8601. */
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
// Provider-specific routing hints
|
// Provider-specific routing hints
|
||||||
|
/** URL canonnique côté fournisseur (contrat v2) : obligatoire pour
|
||||||
|
* PeerTube fédéré — l'embed doit partir de l'instance d'origine. */
|
||||||
|
url?: string;
|
||||||
slug?: string; // Odysee slug when available
|
slug?: string; // Odysee slug when available
|
||||||
channel?: string; // Twitch channel when item represents a channel/live
|
channel?: string; // Twitch channel when item represents a channel/live
|
||||||
// Live status (only providers exposing it, e.g. Twitch, set these)
|
// Live status (only providers exposing it, e.g. Twitch, set these)
|
||||||
|
|||||||
Reference in New Issue
Block a user